Repository navigation
Expand file tree
/
Copy pathstyles.css
More file actions
1752 lines (1688 loc) · 83.2 KB
/
Copy pathstyles.css
File metadata and controls
1752 lines (1688 loc) · 83.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* @microcharts/react — shared stylesheet.
* One artifact, imported once: import '@microcharts/react/styles.css'
* Not per-chart-split. Zero-specificity via :where() so consumer overrides win.
* Theme by overriding --mc-* at any scope. Charts never paint a background.
*/
@layer microcharts.tokens, microcharts.base, microcharts.charts, microcharts.motion;
/* Inline-seat plumbing. NOT theme tokens — a chart writes these on its own root
from its geometry (see `Seat` in shared/Chart.tsx) and the `.mc-inline` rule
reads them back. They are registered rather than merely used because custom
properties inherit by default, and inheriting is exactly wrong here: a seat
belongs to ONE mark. Left unregistered, a `--mc-seat` set anywhere up the tree
— a consumer who picked the same name, or a themed wrapper — would flow down
into every unseated chart beneath it and shift marks that never asked for a
seat (the `var(…, 0)` fallback can't help; it only applies when NOTHING in the
cascade sets the property). `inherits: false` scopes each seat to its own
element, and `initial-value` makes the unseated default guaranteed instead of
incidental. Deliberately outside the layers: @property is not layer-scoped,
and these are internal plumbing, not part of the themeable surface. */
@property --mc-seat {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
@property --mc-seat-mid {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
@layer microcharts.tokens {
:where(:root) {
/* semantic color — matte finish, Okabe-Ito hue geometry (CVD-safe): the
positive stays a bluish-green and the negative a warm vermillion so the
pair survives deuteranopia, but every tone is deepened + slightly
desaturated to read rich and editorial rather than bright/poppy. All
clear ≥ 3:1 on the default white surface (deeper than the source hues). */
--mc-stroke: #1a1917; /* primary data ink — warm graphite near-black */
--mc-positive: #0e7a5f; /* up / good — deep viridian */
--mc-negative: #bd4b2d; /* down / bad — burnt terracotta */
--mc-neutral: #8a8986; /* no-valence data — warm gray */
--mc-accent: #1f6091; /* emphasis: endpoint dot, active — deep steel blue */
--mc-band: color-mix(in oklab, var(--mc-stroke) 8%, transparent);
--mc-moon: #c1922f; /* warm moonlight (MoonPhase lit area) — rich amber */
/* categorical (valence-free series) — matte jewel tones, hue-diverse and
lightness-ordered for grayscale/CVD separation. Ochre omitted-of-yellow
leads; the two blues split by lightness (cerulean light, sapphire deep). */
--mc-cat-1: #d2982f; /* gold */
--mc-cat-2: #5b9fd4; /* azure */
--mc-cat-3: #2e8c66; /* emerald */
--mc-cat-4: #285788; /* sapphire */
/* Terracotta and mauve sat at L* 49.6 / 49.3 — dead mid-lightness, which is
the one place NEITHER knockout ink clears 4.5:1 (they topped out at 4.33
and 4.37). Darkened by ΔE 2.0 and 1.1 — at or under the just-noticeable
threshold — which puts both on the light ink at 4.54/4.52, raises their
contrast on a white page, and moves them further from cat-3's L* 52.2, so
the lightness ordering separates better rather than worse. */
--mc-cat-5: #bc5138; /* terracotta */
--mc-cat-6: #a55a89; /* mauve */
--mc-on-cat-1: rgba(0, 0, 0, 0.9); /* #d2982f 7.5:1 */
--mc-on-cat-2: rgba(0, 0, 0, 0.9); /* #5b9fd4 6.71:1 */
--mc-on-cat-3: rgba(0, 0, 0, 0.9); /* #2e8c66 4.71:1 */
--mc-on-cat-4: rgba(255, 255, 255, 0.96); /* #285788 7.04:1 */
--mc-on-cat-5: rgba(255, 255, 255, 0.96); /* #bc5138 4.56:1 */
--mc-on-cat-6: rgba(255, 255, 255, 0.96); /* #a55a89 4.52:1 */
/* geometry + type */
--mc-stroke-width: 1.5;
--mc-gap: 0.25em;
/* Uniform scale for stroke weight, label size, and small-multiple gap — one
lever for compact (< 1) vs comfortable (> 1) density. The box set by
width/height props is unaffected; density tunes ink + type, not layout. */
--mc-density: 1;
--mc-font: inherit;
/* Face for tabular figures + labels. Tracks --mc-font by default so digits
match the page; override to give numbers a dedicated brand or mono face. */
--mc-font-numeric: var(--mc-font);
--mc-label-size: 0.75em;
--mc-label-weight: 400;
/* Extra optical shift for `.mc-inline` marks on top of the baseline seat
(see .mc-inline). 0 by default — the seat is font-independent; override
per face only if a typeface wants marks a hair off the line. */
--mc-inline-nudge: 0em;
/* Extra optical shift for centred marks only (glyphs, dials, strips) on top
of the cap-band seat. Now 0 by default: the seat lands on the true cap
midpoint, so there is nothing left to correct. It used to be -0.04em
because the old seat was `vertical-align: middle`, which centres on the
x-height and therefore read low beside cap-height digits. Kept as a token
because a display face with an unusual cap ratio may still want a hair of
adjustment; negative lifts. */
--mc-glyph-nudge: 0em;
/* interactive readout chip surface — a tooltip needs a legible backing, so
this is the one place a mark sits on an opaque plane. Defaults to the
system canvas so it adapts with no config; override to match a themed
surface (the docs bind it to their panel tokens). */
--mc-surface: Canvas;
--mc-surface-ink: CanvasText;
--mc-surface-edge: color-mix(in oklab, CanvasText 16%, transparent);
/* The chip's drop shadow. Black, and NOT a `CanvasText` mix like the edge
above — that was the bug: `CanvasText` inverts with the theme, so on dark
the blur rendered as a white halo around the chip instead of a shadow.
A shadow is occlusion; occlusion is dark whatever the surface is, which
is also why this needs no dark twin. On dark it goes quiet and the 1px
edge carries the separation, which is how the ring earns its place. */
--mc-surface-shadow: color-mix(in oklab, #000 28%, transparent);
/* Label ink on a SATURATED SEMANTIC fill (heat cells, progress fill, event
spans) — the deep accent/positive/stroke end of the palette, where a light
ink is the legible one. Measured against the default light tokens:
accent 6.3:1, positive 5.0:1, stroke 16.2:1. It gets a dark twin below,
because the dark palette is deliberately LIFTED — the same fills are the
LIGHTER of the two there and white text drops to 2.7–3.0:1. */
--mc-on-fill: rgba(255, 255, 255, 0.96);
/* Label ink on a CATEGORICAL fill. A different token because the cats are
MID-tone by construction (gold, azure, terracotta), not deep: the light
ink that works on `--mc-accent` reads 2.1–3.7:1 on them in either theme,
while this dark ink clears 4.5:1 on eleven of the twelve default cats
(5.0–8.6:1). The twelfth — light-mode sapphire, the one deep cat — is
exempted where it is used. Not theme-flipped: dark-mode cats are lifted
further, so the dark ink only gets safer. */
--mc-on-cat: rgba(0, 0, 0, 0.9);
/* Label ink for a label that STEPS BACK from the on-fill treatment
(TraceFold's non-critical spans). Its own token rather than the
`--mc-surface` those rules used to borrow — that one is documented as the
readout chip's backing plane, so retargeting the chip repainted in-chart
text as a side effect. */
--mc-on-fill-dim: var(--mc-stroke);
/* motion */
--mc-duration: 300ms;
--mc-easing: cubic-bezier(0.22, 1, 0.36, 1); /* ease-out */
}
/* Dark mode — hand-tuned, never auto-inverted. */
@media (prefers-color-scheme: dark) {
:where(:root) {
/* Matte on dark — lifted for legibility but chroma held back so the tones
read rich/muted, never neon-glow. Warm off-white ink softens the stark. */
--mc-stroke: #eae9e6;
--mc-positive: #45a385; /* sage-emerald */
--mc-negative: #df7856; /* warm terracotta */
--mc-accent: #5ea1cf; /* steel-sky */
--mc-neutral: #9a9a97;
--mc-moon: #e0be6f; /* warm amber on dark surfaces */
/* Categorical is lifted on dark — deep light-mode jewels go muddy on
near-black, so each cat gets a hand-tuned brighter twin (same hue). */
--mc-cat-1: #e2b45c;
--mc-cat-2: #6fb0e0;
--mc-cat-3: #4fb08d;
--mc-cat-4: #6e9bd1;
--mc-cat-5: #e07e5e;
--mc-cat-6: #c486b0;
--mc-on-cat-1: rgba(0, 0, 0, 0.9); /* #e2b45c 9.69:1 */
--mc-on-cat-2: rgba(0, 0, 0, 0.9); /* #6fb0e0 8.07:1 */
--mc-on-cat-3: rgba(0, 0, 0, 0.9); /* #4fb08d 7.2:1 */
--mc-on-cat-4: rgba(0, 0, 0, 0.9); /* #6e9bd1 6.63:1 */
--mc-on-cat-5: rgba(0, 0, 0, 0.9); /* #e07e5e 6.68:1 */
--mc-on-cat-6: rgba(0, 0, 0, 0.9); /* #c486b0 6.73:1 */
/* Lifted fills flip the on-fill ink: white-on-accent falls to 2.7:1 here
(2.96:1 on positive), while this dark ink reads 6.8:1 / 6.3:1. */
--mc-on-fill: rgba(0, 0, 0, 0.9);
}
}
/* Explicit LIGHT — the missing half of the contract. `[data-mc-theme="dark"]`
below has always let a host force dark; there was no way to force light, so
on a dark-OS machine the media query above won no matter what the host said.
That is not a cosmetic mismatch: a host that pins light usually overrides
the palette tokens too, and `--mc-on-fill` is the one nobody thinks to
restate — so the FILLS came out light while their knockout ink stayed on the
dark branch. Measured on the docs site in exactly that state: TraceFold's
span labels 3.28:1, TimeInRange 3.59:1, PartitionStrip 3.76:1, all under the
4.5:1 text floor, on a page whose every other token was correctly light.
Placed after the media query, at the same zero specificity, so it simply
wins on cascade order — the same shape as the dark block below. */
:where([data-mc-theme="light"]) {
--mc-stroke: #1a1917;
--mc-positive: #0e7a5f;
--mc-negative: #bd4b2d;
--mc-accent: #1f6091;
--mc-neutral: #8a8986;
--mc-moon: #c1922f;
--mc-cat-1: #d2982f;
--mc-cat-2: #5b9fd4;
--mc-cat-3: #2e8c66;
--mc-cat-4: #285788;
--mc-cat-5: #bc5138;
--mc-cat-6: #a55a89;
--mc-on-cat-1: rgba(0, 0, 0, 0.9); /* #d2982f 7.5:1 */
--mc-on-cat-2: rgba(0, 0, 0, 0.9); /* #5b9fd4 6.71:1 */
--mc-on-cat-3: rgba(0, 0, 0, 0.9); /* #2e8c66 4.71:1 */
--mc-on-cat-4: rgba(255, 255, 255, 0.96); /* #285788 7.04:1 */
--mc-on-cat-5: rgba(255, 255, 255, 0.96); /* #bc5138 4.56:1 */
--mc-on-cat-6: rgba(255, 255, 255, 0.96); /* #a55a89 4.52:1 */
--mc-on-fill: rgba(255, 255, 255, 0.96);
}
:where([data-mc-theme="dark"]) {
--mc-stroke: #eae9e6;
--mc-positive: #45a385;
--mc-negative: #df7856;
--mc-accent: #5ea1cf;
--mc-neutral: #9a9a97;
--mc-moon: #e0be6f;
--mc-cat-1: #e2b45c;
--mc-cat-2: #6fb0e0;
--mc-cat-3: #4fb08d;
--mc-cat-4: #6e9bd1;
--mc-cat-5: #e07e5e;
--mc-cat-6: #c486b0;
--mc-on-cat-1: rgba(0, 0, 0, 0.9); /* #e2b45c 9.69:1 */
--mc-on-cat-2: rgba(0, 0, 0, 0.9); /* #6fb0e0 8.07:1 */
--mc-on-cat-3: rgba(0, 0, 0, 0.9); /* #4fb08d 7.2:1 */
--mc-on-cat-4: rgba(0, 0, 0, 0.9); /* #6e9bd1 6.63:1 */
--mc-on-cat-5: rgba(0, 0, 0, 0.9); /* #e07e5e 6.68:1 */
--mc-on-cat-6: rgba(0, 0, 0, 0.9); /* #c486b0 6.73:1 */
--mc-on-fill: rgba(0, 0, 0, 0.9);
}
/* Presets = token bundles. Visual only. */
/* "editorial" — hairline, sharp, a single restrained red endpoint accent.
Ink is left to the active theme (never pinned to a light value) so the
preset stays legible on dark surfaces too — its identity is the weight and
the accent, not a fixed grey. */
/* Each preset also answers to [data-mc-preset] — the page-global form (a docs
or app shell sets it on <html>) — so preset bundles have ONE source here and
hosts never re-declare them. Per-instance [data-mc-theme] wins by proximity. */
:where([data-mc-theme="editorial"], [data-mc-preset="editorial"]) {
--mc-accent: #a32236; /* matte claret — restrained, not signal-red */
--mc-stroke-width: 1;
}
:where([data-mc-theme="mono"], [data-mc-preset="mono"]) {
--mc-positive: var(--mc-stroke);
--mc-negative: var(--mc-stroke);
--mc-accent: var(--mc-stroke);
--mc-neutral: var(--mc-stroke);
--mc-moon: var(--mc-stroke);
}
/* vivid never claims --mc-accent: the accent is the consumer's brand voice;
the preset only turns up weight + valence. (editorial's red IS its identity,
so that one keeps its pin.) */
:where([data-mc-theme="vivid"], [data-mc-preset="vivid"]) {
--mc-positive: #0f9e78; /* louder than default, still matte not neon */
--mc-negative: #e24d2e;
--mc-stroke-width: 2;
}
/* "print" — paper output: near-black ink at a hairline weight; valence
deepened for CMYK so up/down survive a colour print; band barely there. */
:where([data-mc-theme="print"], [data-mc-preset="print"]) {
--mc-stroke: #1a1a1a;
--mc-neutral: #666666;
--mc-positive: #0c6249;
--mc-negative: #a33f22;
--mc-accent: #14507a;
--mc-moon: #7a5a12;
--mc-band: color-mix(in oklab, #1a1a1a 9%, transparent);
--mc-stroke-width: 1.25;
}
/* "eink" — grayscale e-paper: no chroma (the panel can't show it), so sign
rides lightness (positive black vs negative gray, mirroring forced-colors)
and strokes thicken for low-DPI, low-refresh legibility. */
:where([data-mc-theme="eink"], [data-mc-preset="eink"]) {
--mc-stroke: #000000;
--mc-positive: #000000;
--mc-negative: #595959;
--mc-neutral: #8c8c8c;
--mc-accent: #000000;
--mc-moon: #000000;
--mc-band: color-mix(in oklab, #000000 14%, transparent);
--mc-stroke-width: 2;
}
/* mono + eink are the two ACHROMATIC presets: mono collapses every semantic
token onto `--mc-stroke`, and eink's own comment says "no chroma (the panel
can't show it)". Neither remapped `--mc-cat-*`, so the nine categorical
charts kept full jewel-tone colour inside a preset that had just declared it
unavailable — a SegmentedBar in `eink` shipped six hues to a grayscale panel.
Both now ride one lever, `--mc-stroke`, since each already pins that: six
tints, lightness-ordered, so the series order survives.
The range is deliberately narrow (16% → 5%). A tint of the ink over the page
is mid-grey in the middle of a wider ramp, and there is then NO single label
ink that clears 4.5:1 on every step — the light ink fails the dark steps and
the dark ink fails the light ones. Keeping every step light means the
ordinary `--mc-stroke` label ink stays legible on all six in both themes
(measured: 4.6:1 on the darkest step against a dark page, 12:1 against a
light one), which is why these presets take no `--mc-on-cat` override. A
whisper of ordering is the honest reading anyway: in a one-ink preset the
label is the identifier and colour is explicitly not carrying meaning.
`print` keeps the chromatic palette on purpose — it is a COLOUR-output
context (it only deepens valence for CMYK), not an achromatic one. */
:where(
[data-mc-theme="mono"],
[data-mc-preset="mono"],
[data-mc-theme="eink"],
[data-mc-preset="eink"]
) {
--mc-cat-1: color-mix(in oklab, var(--mc-stroke) 16%, transparent);
--mc-cat-2: color-mix(in oklab, var(--mc-stroke) 13%, transparent);
--mc-cat-3: color-mix(in oklab, var(--mc-stroke) 11%, transparent);
--mc-cat-4: color-mix(in oklab, var(--mc-stroke) 9%, transparent);
--mc-cat-5: color-mix(in oklab, var(--mc-stroke) 7%, transparent);
--mc-cat-6: color-mix(in oklab, var(--mc-stroke) 5%, transparent);
--mc-on-cat: var(--mc-stroke);
}
/* print/eink pin a fixed light-surface ink — right for paper, but on a dark
viewer that ink sinks into the field. Give them hand-tuned dark twins: eink
flips to true night-mode (light ink on black — a real e-paper mode), print
lifts to a "dark paper" ink. Weight + band identity (set above) carry over;
only the pinned colours are re-tuned. mono/vivid/editorial already leave ink
to the active theme, so they need no twin. Both dark signals are covered —
@media(prefers-color-scheme) and an explicit [data-mc-theme="dark"] scope —
and each sits AFTER its light block so it wins at equal (zero) specificity. */
@media (prefers-color-scheme: dark) {
:where([data-mc-theme="print"], [data-mc-preset="print"]) {
--mc-stroke: #e8e6e1;
--mc-neutral: #9a958c;
--mc-positive: #4aa588;
--mc-negative: #e08a63;
--mc-accent: #6aa9d3;
--mc-moon: #e0be6f;
--mc-band: color-mix(in oklab, #e8e6e1 9%, transparent);
}
:where([data-mc-theme="eink"], [data-mc-preset="eink"]) {
--mc-stroke: #ffffff;
--mc-positive: #ffffff;
--mc-negative: #b0b0b0;
--mc-neutral: #808080;
--mc-accent: #ffffff;
--mc-moon: #ffffff;
--mc-band: color-mix(in oklab, #ffffff 14%, transparent);
}
}
:where([data-mc-theme="dark"]) :where([data-mc-theme="print"], [data-mc-preset="print"]),
:where([data-mc-theme="dark"][data-mc-preset="print"]) {
--mc-stroke: #e8e6e1;
--mc-neutral: #9a958c;
--mc-positive: #4aa588;
--mc-negative: #e08a63;
--mc-accent: #6aa9d3;
--mc-moon: #e0be6f;
--mc-band: color-mix(in oklab, #e8e6e1 9%, transparent);
}
:where([data-mc-theme="dark"]) :where([data-mc-theme="eink"], [data-mc-preset="eink"]),
:where([data-mc-theme="dark"][data-mc-preset="eink"]) {
--mc-stroke: #ffffff;
--mc-positive: #ffffff;
--mc-negative: #b0b0b0;
--mc-neutral: #808080;
--mc-accent: #ffffff;
--mc-moon: #ffffff;
--mc-band: color-mix(in oklab, #ffffff 14%, transparent);
}
}
@layer microcharts.base {
:where(.mc-root) {
display: inline-block;
vertical-align: middle;
overflow: visible;
font: var(--mc-font);
/* Let the drawing follow a CSS width instead of letterboxing inside it.
`Chart` emits width AND height as presentation attributes, so a consumer
`className="w-full"` beat the width attribute and left the height one
standing: `preserveAspectRatio="xMidYMid meet"` then fitted an 80x6 mark
into a 335x6 box at scale 1 and centred it, painting 127px of dead space
each side. It read as a chart that failed to load.
`height: auto` releases the second axis so the viewBox ratio drives it.
It is a CSS declaration, so it outranks the presentation attribute; it is
inside `:where()`, so any consumer rule still outranks it. With no
consumer CSS at all the used height is unchanged — `auto` against a
definite width resolves through the same ratio the attribute encoded.
`max-width: 100%` stops a fixed-width mark overflowing a container
narrower than itself, mirroring `[data-mc-host]` so the static entry and
its interactive twin size identically. There is deliberately NO `align-self` here: it would
defeat a parent's `align-items` outright rather than lose a specificity
contest, so it broke this package's own `.mc-group { align-items: center }`
and made a static chart align differently from its interactive twin in
the same flex row — the "one component, two layouts" bug in a new axis. */
height: auto;
max-width: 100%;
/* density-scaled stroke weight consumed by the ink + width roles below, so
one --mc-density retunes every stroke without touching the base token.
This is the token a MARK reads, in the stylesheet and in an inline
`strokeWidth` alike — thirty inline declarations across twenty charts used
to reach for the base `--mc-stroke-width` directly and so opted their
primary mark out of `--mc-density` while every stroke around it scaled. A
chart wants `--mc-stroke-width` only to declare a DELIBERATE exemption,
and then it should say so (see the annotation hairlines). */
--mc-sw: calc(var(--mc-stroke-width) * var(--mc-density));
}
/* Chart-only table cells: kill the font strut so a lone SVG seats the row
height instead of a 1em line-box around an inline mark. The two HTML-text
charts (Delta, TokenConfidence) are excluded: their marks ARE text, so the
strut is load-bearing — and only their LIVE wrappers match these selectors,
which made a hydrating cell 2px shorter than its server render (the
"one component, two layouts" bug, in the row-height axis). */
:where(td:has(> .mc-root:only-child)),
:where(td:has(> [data-mc-host]:only-child:not(.mc-delta-live, .mc-token-confidence))),
:where(td:has(> [class$="-live"]:only-child:not(.mc-delta-live, .mc-tc-live))),
:where(td:has(> [class$="-interactive"]:only-child)) {
line-height: 0;
}
:where(.mc-root text) {
font-variant-numeric: tabular-nums;
font-family: var(--mc-font-numeric);
font-weight: var(--mc-label-weight);
/* Density scales the label, but only up to 1.25 — past that the text outgrows
the gutter the geometry reserved for it and paints outside the viewBox
(`.mc-root` is overflow: visible, so it spills into the page rather than
clipping). Gutters are computed server-side from a character-count
estimate and cannot see a runtime CSS variable, so the cap is the only
place this can be enforced. Measured on the worst case in the catalog
(dot-plot, a caller-supplied all-caps row label, whose prose gutter has the
least slack): no spill at 0.8 / 1 / 1.25, and 7.5 units of spill at 1.5.
Compact density (< 1) is always safe — it shrinks the text inside a gutter
already reserved for the larger size. Stroke and gap keep scaling
unclamped below; neither is bounded by a reserved text box. */
/* `--mc-label-px` is the CHART's own computed size, pinned on its host from
geometry — the number the gutter was reserved for, so it is what has to
paint. `--mc-label-size` is the APP's token and stands in only for a chart
that pins nothing. They used to be one name, which meant an app setting
the token silently overrode the geometry on exactly the charts that paint
labels, and a chart's own pin overrode the app everywhere else. */
font-size: calc(var(--mc-label-px, var(--mc-label-size)) * min(1.25, var(--mc-density)));
fill: var(--mc-stroke);
}
/* Labels seated ON a solid mark (HeatCell upper steps, TimeInRange zones,
EventTimeline spans) take the on-fill ink. A stylesheet rule, not an SVG
`fill` attribute — the `.mc-root text` fill above is a CSS declaration and
outranks any presentation attribute, which silently kept these labels in
stroke ink (dark-on-dark on hot cells). Placed after it so source order
decides between the two zero-specificity rules. */
:where(.mc-root text[data-mc-on-fill]) {
fill: var(--mc-on-fill);
}
/* small-multiples wrapper — layout-neutral by default */
:where(.mc-group) {
display: inline-flex;
flex-wrap: wrap;
gap: calc(var(--mc-gap) * var(--mc-density));
align-items: center;
}
/* Word-sized chart in running prose. Wraps any SVG mark next to digits;
leave <Delta> bare — it owns baseline. Ships in styles.css so hosts only
add className="mc-inline".
Alignment is a baseline SEAT, not a middle+nudge: an inline-flex box whose
only child is an SVG takes its baseline from the SVG's bottom edge, so
`vertical-align: baseline` stands the mark ON the text baseline exactly
like a glyph — bars sit where letters sit. Metric-independent: the old
`vertical-align: middle` + fixed nudge drifted with every typeface
(x-height decides where "middle" is; serif vs sans marks landed on
different lines). --mc-inline-nudge remains as a pure optical extra. */
:where(.mc-inline) {
display: inline-flex;
align-items: center;
vertical-align: baseline;
margin-inline: 0.2em;
translate: 0 var(--mc-inline-nudge);
}
/* The seat itself. The wrapper always baseline-seats (above); this rule then
shifts the MARK to where it actually belongs on the line, using the two
custom properties the chart emitted from its own geometry (see `Seat` in
shared/Chart.tsx). No per-chart CSS, no `:has()` allowlist of class names —
the numbers come from the same source as the drawing, so a chart whose
padding changes cannot drift out of alignment.
Two things make this work:
- `100%` in a translate resolves against the element's OWN height, so one
unitless fraction seats the mark at any rendered size — authored px, a CSS
height override, or a fluid container. Nothing has to know the used size.
- `translate` is its own property, not `transform`, so it composes with the
motion engine's transforms instead of overwriting them.
The shift is visual only: transforms don't affect layout, so the wrapper's
baseline — and therefore the line box and the leading around it — is
untouched. A seated mark never reflows the sentence it sits in.
`--mc-seat-mid` is 0 for floor marks and 1 for centred ones; multiplying the
cap term by it keeps both modes in a single declaration. Charts that don't
emit a seat get 0 and keep the legacy baseline seat. */
:where(.mc-inline .mc-root) {
translate: 0
calc(var(--mc-seat, 0) * 100% + var(--mc-seat-mid, 0) * (var(--mc-glyph-nudge) - 0.36em));
}
/* `cap` is exactly the half-cap-height this wants and needs no per-font
tuning; `0.36em` above is the fallback for browsers without it (≈ half of a
0.72em typical cap height). `.mc-root` carries `font: inherit`, so `cap`
here resolves against the SURROUNDING PROSE — not `--mc-label-size`, which
only ever applies to `.mc-root text`. */
@supports (height: 1cap) {
:where(.mc-inline .mc-root) {
translate: 0
calc(var(--mc-seat, 0) * 100% + var(--mc-seat-mid, 0) * (var(--mc-glyph-nudge) - 0.5cap));
}
}
/* An INTERACTIVE wrapper hoists its chart's seat up to itself (see
`useSeatHoist` in shared/live-region.tsx) and marks itself `data-mc-seated`.
Seating the wrapper instead of the SVG moves the whole box — the mark AND
the `.mc-spark-readout` chip the wrapper positions — so the chip stays
pinned to the mark it annotates instead of floating a seat's worth above it.
Identical declaration to the rule above: `100%` resolves against the
wrapper's own height, which IS the SVG's height, and `cap` resolves against
the same inherited prose font. The SVG then must NOT seat itself again. */
:where(.mc-inline [data-mc-seated]) {
translate: 0
calc(var(--mc-seat, 0) * 100% + var(--mc-seat-mid, 0) * (var(--mc-glyph-nudge) - 0.36em));
}
:where(.mc-inline [data-mc-seated] > .mc-root) {
translate: none;
}
@supports (height: 1cap) {
:where(.mc-inline [data-mc-seated]) {
translate: 0
calc(var(--mc-seat, 0) * 100% + var(--mc-seat-mid, 0) * (var(--mc-glyph-nudge) - 0.5cap));
}
}
:where(.mc-inline .mc-status) {
align-self: center;
}
:where(.mc-inline .mc-trend) {
width: 0.95em;
height: 0.95em;
}
/* Focusable interactive charts never lose a visible ring.
The tab stop is the WRAPPER span (`wrap()` stamps `data-mc-host`), never the
`<svg>` — no static sets `tabIndex`, so a `.mc-root:focus-visible` rule can
never match and every chart fell back to the UA outline. Keep `.mc-root`
too: a consumer may make the SVG itself focusable in a custom host. */
:where([data-mc-host]:focus-visible, .mc-root:focus-visible) {
outline: 2px solid var(--mc-accent);
outline-offset: 2px;
/* the wrapper is line-height: 0 and hugs the mark; a hair of radius keeps
the ring from reading as a hard box around a round glyph */
border-radius: 0.2em;
}
/* <Delta> — inline text metric; glyph + number share the valence color via
currentColor. Direction is double-encoded (triangle shape + color). */
:where(.mc-delta) {
display: inline-flex;
/* Align to the text BASELINE, not the x-height middle: inline-flex +
vertical-align:middle rode the box high beside running text ("Revenue
▲ +12.4%" sat above the line). align-items:baseline lets the number set
the box baseline; vertical-align:baseline seats it on the sentence line.
Metric-independent — scales with font-size, no magic numbers. */
align-items: baseline;
gap: var(--mc-gap);
font: var(--mc-font);
font-family: var(--mc-font-numeric);
font-variant-numeric: tabular-nums;
color: var(--mc-neutral);
vertical-align: baseline;
line-height: 1;
}
:where(.mc-delta[data-mc-valence="pos"]) {
color: var(--mc-positive);
}
:where(.mc-delta[data-mc-valence="neg"]) {
color: var(--mc-negative);
}
:where(.mc-delta .mc-delta-glyph) {
fill: currentColor;
flex: none;
/* The NUMBER owns the box baseline (align-items:baseline on the parent); the
glyph must not participate or the taller svg drags the baseline up and the
whole delta rides high in a sentence. Center it on the number instead. */
align-self: center;
}
:where(.mc-delta .mc-delta-num) {
font-weight: 600;
}
/* interactive Delta wrapper: hosts the live region + pulse (see motion layer) */
:where(.mc-delta-live) {
position: relative;
display: inline-flex;
/* MUST match the static .mc-delta (baseline, not middle): the inline-flex
takes its baseline from the inner .mc-delta's number, so this seats the
live Delta on the same line as the static one — otherwise toggling
interactive/animated shifts it vertically. */
vertical-align: baseline;
}
/* interactive TrendArrow wrapper — same live/pulse pattern at glyph size */
:where(.mc-trend-live) {
position: relative;
display: inline-flex;
vertical-align: middle;
}
/* interactive StatusDot wrapper — live region host */
:where(.mc-status-live) {
position: relative;
display: inline-flex;
vertical-align: middle;
}
/* Data change — when the host sends new `data`, an interactive chart TRAVELS
to the new reading instead of cutting to it. A mark that teleports on every
update reads as a static image being replaced; a mark that moves is the same
mark holding a new value, which is what actually happened.
Three things make this one rule instead of a hundred:
- `[data-mc-host]` scopes it to interactive wrappers (`src/shared/
interactive.ts`). Static entries never carry it, so the static-first
contract holds structurally rather than by convention.
- `[data-mc-ink]`, or `[data-mc-cat]` for the categorical family and
`[data-mc-cone]` for graded bands, is the discriminator, and it costs
nothing because those attributes already mark every mark that encodes
something. (A categorical segment travels
on its WIDTH, which is a continuous quantity; its category is carried by
the attribute, and that swaps outright.) A focus or selection ring
carries `data-mc-active` and neither of these in ~100 entries — that is
the house pattern, so rings fall out of this selector for free and keep
SNAPPING to the unit they name. Mid-glide a
ring encloses no unit while the screen reader has already announced the
new one. `focus-ring-symmetry.browser.test.tsx` exists because a ring
once inherited a geometry transition and lerped across a whole beam.
Because the gate IS the role, a prop must never take a role away:
`ink-role-stability.test.ts` fails a chart that drops `data-mc-ink` to
`undefined` when a `color` prop is passed, which used to leave those
marks out of this rule, out of the forced-colors remap, and out of every
other rule keyed on a role.
- `[data-mc-ui]` excludes the interaction overlay, whose own response
motion is the transform glide further down and is timed to the pointer,
not to the data.
Interpolating between two readings paints values that are not in the data —
a bar easing 5 to 8 passes through 6 and 7. That is admissible only for a
CONTINUOUS quantity, where the reading is "this value is changing", and only
for the mark's own geometry: label and readout TEXT is never transitioned,
so the number on screen is always one the host actually sent. Charts whose
mark names a discrete state rather than a quantity (dice pips, moon phase,
tally marks, trend arrow, hourglass, status dot) have no wrapper here and
swap outright.
`d` is absent because no engine has ever had a CSS geometry property for it,
so the ~60 path-based charts cut rather than travel. `x1`/`y1`/`x2`/`y2`
are absent for the same reason and it is easy to miss: SVG2 specified them
as geometry properties, no engine implemented them, and listing them here
is inert — so a `<line>` NEVER travels, whatever role it carries. That is
load-bearing when a chart mixes shapes: OHLC's bodies are rects and its
wicks are lines, so its bodies deliberately take no ink role rather than
glide away from their own high and low. `data-change-glide.browser.test.tsx`
asserts the platform fact so this comment cannot rot. The properties that
ARE listed became animatable in Safari 17.4 and Firefox 128 against a
Safari 16.4 floor, so an older engine gets today's behaviour: it snaps.
All stated degradations, not silent ones.
200ms, not `--mc-duration`'s 300ms: a feed ticking faster than the
transition would leave the mark permanently trailing its own data. Consumers
opt out with one zero-specificity line —
`.my-chart [data-mc-ink] { transition: none }` — and `prefers-reduced-motion`
already strips it in the block below. */
:where(
[data-mc-host]
.mc-root
:is([data-mc-ink], [data-mc-cat], [data-mc-cone]):not([data-mc-ui], [data-mc-ui] *)
) {
transition-property: x, y, width, height, cx, cy, r;
transition-duration: 200ms;
transition-timing-function: var(--mc-easing);
}
/* @mc-chart fill-word */
/* interactive FillWord: the ink edge glides along the word. `clip-path` is not
a geometry property, so this stays outside the shared data-change rule above
and only borrows its 200ms. Reduced-motion is handled by the block below. */
:where(.mc-fillword-live text[data-mc-ink="accent"]) {
transition: clip-path 200ms var(--mc-easing);
}
/* @mc-chart-end */
/* @mc-chart fat-digits */
/* interactive FatDigits: the weight eases on variable fonts (snaps otherwise).
A font axis is not a geometry property, so this stays outside the shared
data-change rule and only borrows its 200ms. */
:where(.mc-fat-live tspan) {
transition: font-weight 200ms var(--mc-easing);
}
/* @mc-chart-end */
}
@layer microcharts.charts {
/* ink roles — marks carry data-mc-ink; color/width come from tokens so a
single --mc-* override retunes every chart. */
:where(.mc-root [data-mc-ink="data"]) {
fill: none;
stroke: var(--mc-stroke);
stroke-width: var(--mc-sw);
stroke-linejoin: round;
stroke-linecap: round;
}
:where(.mc-root [data-mc-ink="accent"]) {
fill: var(--mc-accent);
stroke: none;
}
/* accent on stroked marks (now-line, focus guides) — element-split like ghost:
an open polyline with a plain accent FILL would paint a wedge and lose its
stroke, so line-shaped accents stroke instead. */
:where(.mc-root :is(path, line, polyline)[data-mc-ink="accent"]) {
fill: none;
stroke: var(--mc-accent);
}
/* width roles — orthogonal to ink (color) roles, composable on any stroked
mark. Ratios of the primary width so presets and prefers-contrast retune
every stroke in a chart, not just the data line. At the default 1.5 these
resolve to 1 / 0.75 / 0.5 — the weights the charts were hand-tuned to. */
:where(.mc-root [data-mc-w="full"]) {
stroke-width: var(--mc-sw);
}
:where(.mc-root [data-mc-w="support"]) {
stroke-width: calc(var(--mc-sw) * 2 / 3);
}
:where(.mc-root [data-mc-w="tick"]) {
stroke-width: calc(var(--mc-sw) / 2);
}
:where(.mc-root [data-mc-w="hair"]) {
stroke-width: calc(var(--mc-sw) / 3);
}
/* interaction overlay — the mark that names the unit under the pointer or the
keyboard cursor. `data-mc-active` is a marker, not a state: hover vs pin is
already carried by `data-mc-w`, which ~20 charts tune deliberately in both
directions (Constellation pins at `full`, ChangePoint inverts the pair), and
one channel per job keeps that tuning intact.
Three tokens, so the SHAPE of the active state is themeable and not only its
color. Restyling used to mean hand-scoping rules onto `data-mc-*` attributes
from outside the package, which is a private-API dependency:
--mc-active-stroke ring color (default: --mc-accent)
--mc-active-fill wash inside the ring (default: --mc-on-fill)
--mc-active-fill-opacity its strength (default: 0.2; 0 = outline only)
--mc-rest-opacity every OTHER mark, while something is active (default: 1)
The wash is not decoration, it is the fix for a real blind spot: an accent
hairline over an accent-emphasized mark is invisible by construction, and 38
charts paint exactly one mark `data-mc-ink="accent"` (SparkBar's endpoint
bar, Pareto's leader, Thermometer's reading). `--mc-on-fill` resolves it
without a second element per chart, because it is DEFINED as the ink that
reads on a saturated data fill — `theming-contract.test.ts` already pins it
at ≥ 4.5:1 on accent/positive/stroke in both themes — and it is near the
page color by that same construction, so the wash stays invisible inside a
ring that encloses empty plot rather than a mark.
Closed primitives only. A `path`/`line`/`polyline` overlay is an open strand
(EnsembleGhosts' member, MicroDonut's arc) and filling one paints a wedge —
the same element split the ink roles make. This block sits BEFORE the muted
role below on purpose: a chart whose hover crosshair is `muted` and whose
pin is `accent` (DepthWedge, Seismogram) keeps the quiet half quiet, and
only the accent half answers to `--mc-active-stroke`. */
:where(.mc-root [data-mc-active]) {
stroke: var(--mc-active-stroke, var(--mc-accent));
}
:where(.mc-root :is(rect, circle, ellipse, polygon)[data-mc-active]) {
fill: var(--mc-active-fill, var(--mc-on-fill));
fill-opacity: var(--mc-active-fill-opacity, 0.2);
}
/* "dim the rest" — the third shape a host asks for, and the one no consumer
can write from outside, because only the chart knows which mark is active.
`:has()` reads the overlay's own presence, so this costs no JS and no
attribute on the wrapper. The overlay and the `data-mc-ui` response marks
stay at full strength; the default of 1 leaves the rule inert until a host
opts in. Pair it with `--mc-active-fill: var(--mc-accent)` +
`--mc-active-fill-opacity: 1` to repaint the picked unit solid over its own
dimmed self — that combination is the whole "lift one, dim the rest"
treatment, in two declarations on the host.
`:not([opacity])` is load-bearing, not tidiness. A CSS declaration beats a
presentation attribute, so without it this rule would flatten a mark that
encodes something IN its opacity the moment anything was picked —
EventRaster dims a lane that way, and every dimmed lane would have snapped
to full strength under the pointer. A mark that spells its own opacity out
keeps it and opts out of the host's dim; a mark that does not is free to
take one. */
:where(
.mc-root:has([data-mc-active])
:is([data-mc-ink], [data-mc-cat]):not(
[data-mc-active],
[data-mc-ui],
[data-mc-ui] *,
[opacity]
)
) {
opacity: var(--mc-rest-opacity, 1);
}
:where(.mc-root [data-mc-ink="muted"]) {
fill: none;
stroke: var(--mc-neutral);
}
/* direct value labels — quieter than data ink, never the loudest mark */
:where(.mc-root text[data-mc-ink="label"]) {
fill: var(--mc-neutral);
stroke: none;
}
/* filled marks (min/max endpoints) — solid data ink, no stroke */
:where(.mc-root [data-mc-ink="point"]) {
fill: var(--mc-stroke);
stroke: none;
}
/* solid bars — neutral fill; valence variants encode sign (redundant with
position, never color-alone) */
:where(.mc-root [data-mc-ink="bar"]) {
fill: var(--mc-stroke);
stroke: none;
}
:where(.mc-root [data-mc-ink="positive"]) {
fill: var(--mc-positive);
stroke: none;
}
:where(.mc-root [data-mc-ink="negative"]) {
fill: var(--mc-negative);
stroke: none;
}
/* valence on stroked marks — AFTER the fill rules so cascade wins under
`:where()` (zero specificity). Bare `path` is opt-in via [fill="none"] so
filled wedges (DepthWedge, NetFlow) keep their fill. */
:where(.mc-root :is(line, polyline)[data-mc-ink="positive"]) {
fill: none;
stroke: var(--mc-positive);
}
:where(.mc-root :is(line, polyline)[data-mc-ink="negative"]) {
fill: none;
stroke: var(--mc-negative);
}
:where(.mc-root path[data-mc-ink="positive"][fill="none"]) {
stroke: var(--mc-positive);
}
:where(.mc-root path[data-mc-ink="negative"][fill="none"]) {
stroke: var(--mc-negative);
}
/* filled no-valence marks (flat trend glyph, off states) */
:where(.mc-root [data-mc-ink="neutral"]) {
fill: var(--mc-neutral);
stroke: none;
}
/* normal-range band + area fill — real data, lowest z, never decoration */
:where(.mc-root [data-mc-ink="band"]) {
fill: var(--mc-band);
stroke: none;
}
:where(.mc-root [data-mc-ink="fill"]) {
fill: var(--mc-stroke);
fill-opacity: 0.12;
stroke: none;
}
/* intensity cell (ActivityGrid/HeatCell) — color IS the variable; opacity per
discrete level set inline. Paired with the numeric summary for a11y. */
:where(.mc-root [data-mc-ink="cell"]) {
fill: var(--mc-accent);
stroke: none;
}
/* calibrated VALUE cells (HeatCell/HeatStrip): mix accent into the band per
discrete step — the unitless --mc-cell-mix (0–100) is set inline by the
chart. A mix ramp stays distinct on both light and dark surfaces where an
opacity-only ramp collapses once --mc-accent is already dark. The fill
lives HERE (not inline) so the forced-colors mapping below can override. */
:where(.mc-root [data-mc-cell-mix]) {
fill: color-mix(
in oklab,
var(--mc-cell-color, var(--mc-accent)) calc(var(--mc-cell-mix, 100) * 1%),
var(--mc-band)
);
}
/* ForecastCone uncertainty bands. Same move as the cell ramp above, for the
same reason: the paint is dynamic (series accent, or the `color` prop), so
the CHART sets a variable and the fill lives here. It used to set `fill`
and `fill-opacity` inline under a `mc-cone-band` class that no rule ever
matched, which put the chart's primary encoding out of reach of both the
theming contract and the forced-colors mapping below — a 13%-opacity brand
accent, preserved verbatim by `forced-color-adjust: none`, is not a visible
cone in High Contrast Mode. */
:where(.mc-root [data-mc-cone]) {
fill: var(--mc-cone-color, var(--mc-accent));
fill-opacity: var(--mc-cone-opacity, 0.13);
stroke: none;
}
/* IconArray empty unit + CoverageStrip gap — a "measured nothing here" slot,
never a void: faint band fill + hairline neutral outline. Static, so the
color lives here (was inline) to earn a forced-colors mapping below. The
FILLED unit stays inline: its fill is dynamic (accent/positive/negative by
`positive`), so it can't move to a static ink-role rule. */
:where(.mc-root [data-mc-ink="unit-off"]) {
fill: var(--mc-band);
stroke: var(--mc-neutral);
stroke-opacity: 0.4;
stroke-width: calc(var(--mc-sw) / 3);
}
:where(.mc-root [data-mc-ink="gap"]) {
fill: var(--mc-band);
stroke: var(--mc-neutral);
stroke-opacity: 0.35;
stroke-width: calc(var(--mc-sw) / 3);
}
/* Ghost = low-precision context behind the primary mark (RateVolume volume
bars, QuadrantDot peer dots, CyclePlot slot lines, ChangePoint regime means,
EnsembleGhosts member paths). ELEMENT-SPLIT: filled marks (bar/dot) take a
faint fill; stroked marks (line/path) take a faint STROKE — a single role
with a plain `fill: neutral` would fill an open polyline into a wedge and
kill its stroke. Neutral lives here (not inline) to earn forced-colors. */
:where(.mc-root :is(rect, circle, ellipse, polygon)[data-mc-ink="ghost"]) {
fill: var(--mc-neutral);
fill-opacity: 0.18;
stroke: none;
}
:where(.mc-root :is(path, line, polyline)[data-mc-ink="ghost"]) {
fill: none;
stroke: var(--mc-neutral);
stroke-opacity: 0.5;
}
/* QuadrantDot quadrant tint — a whisper of accent behind the focal's cell.
Decorative: membership is carried by dot position + summary, so it drops out
entirely under forced-colors (mapping below). */
:where(.mc-root [data-mc-ink="region"]) {
fill: var(--mc-accent);
fill-opacity: 0.08;
stroke: none;
}
/* PercentileLadder tail flag — the emphasised accent tick/dot. Element-split:
the line strokes, the dot fills. `color` prop still overrides inline. */
:where(.mc-root line[data-mc-ink="flag"]) {
stroke: var(--mc-accent);
}
:where(.mc-root circle[data-mc-ink="flag"]) {
fill: var(--mc-accent);
}
/* categorical fill roles — the --mc-cat-N palette as roles, so segment charts
emit one short attribute instead of an inline var() string per mark. */
:where(.mc-root [data-mc-cat="1"]) {
fill: var(--mc-cat-1);
stroke: none;
}
:where(.mc-root [data-mc-cat="2"]) {
fill: var(--mc-cat-2);
stroke: none;
}
:where(.mc-root [data-mc-cat="3"]) {
fill: var(--mc-cat-3);
stroke: none;
}
:where(.mc-root [data-mc-cat="4"]) {
fill: var(--mc-cat-4);
stroke: none;
}
:where(.mc-root [data-mc-cat="5"]) {
fill: var(--mc-cat-5);
stroke: none;
}
:where(.mc-root [data-mc-cat="6"]) {
fill: var(--mc-cat-6);
stroke: none;
}
/* TraceFold + PartitionStrip — dense span/segment strips. Shared presentation
lives here (not per-mark attributes): these charts render 20–40 rects and
the attribute count is their SSR hot path. Label ink over saturated fills
= ON_FILL_INK (core/color.ts) — the one sanctioned on-fill literal. */
:where(.mc-trace, .mc-partition) :where(rect) {
shape-rendering: crispEdges;
rx: 0.5;
}
/* A mark thinner than one viewBox unit gets its anti-aliasing back.
`crispEdges` snaps every edge to the device pixel grid, so a rect whose
extent rounds to zero device pixels paints NOTHING — and word-sized charts
render at ~1 unit = 1 px, where "sub-unit" and "sub-pixel" are the same
thing. Measured in Chromium at 1u = 1px, a 30x0.5 rect flush to the box
bottom: `crispEdges` 0 ink, `auto` 7.1 ink, against 0 for a true zero.
Several charts floor a magnitude at 0.5 units precisely so a tiny-but-real
value stays visible (sparkbar/geometry.ts:201, funnel, event-timeline,
streak-spark, net-flow, rate-volume) — the floor and the hint cancelled
out, and a bin holding 4 events painted exactly what a bin holding 0
painted. That is a lie factor above 1, so it is a bug, not a nicety.
Sub-unit is the only safe trigger: below 1 unit whether a mark survives the
snap depends on where its edges land on the grid, which nothing server-side
can know. At and above 1 unit the mark always paints, so it keeps the crisp
hint. A true zero is untouched — `height="0"` does not start with "0." —
and it must stay untouched: zero and no-data part company deliberately
(a null emits no rect at all).
This is the last word on `shape-rendering` in the layer, so it also relaxes
the TraceFold/PartitionStrip rule above for a sub-unit segment. Ordering,
not specificity — both selectors are `:where()`, so keep this one last.
Guarded by src/test/sub-pixel-ink.browser.test.tsx. */
:where(.mc-root rect[width^="0."], .mc-root rect[height^="0."]) {
shape-rendering: auto;
}
/* anchor/baseline stay SVG attributes on the marks — the craft gate computes
text extents from raw attributes; only paint moves here.
`--mc-on-fill` rather than the literal it used to repeat: the token gained a
dark twin (see the tokens layer) precisely because these labels were the
charts that needed it, and a rule that spells the value out cannot follow. */
:where(.mc-trace, .mc-partition) :where(text) {
font-weight: 600;
fill: var(--mc-on-fill);
}
/* A label on a CATEGORICAL fill takes that fill's OWN ink.
`--mc-on-cat-N` is generated per palette by scripts/gen-on-cat-ink.mjs,
which picks whichever of the two inks has more contrast on that exact hex