-
-
Notifications
You must be signed in to change notification settings - Fork 153
Expand file tree
/
Copy pathindex.css
More file actions
1163 lines (1095 loc) · 63.3 KB
/
Copy pathindex.css
File metadata and controls
1163 lines (1095 loc) · 63.3 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
@import "tailwindcss";
@import "tw-animate-css";
/* Codex paints submitted user messages onto a near-white terminal-width row. The mirror's light
theme inversion turns that fill nearly black; on a narrow phone it reads as a heavy black bar
rather than message emphasis. The codex adapter marks only that exact ANSI fill. Desktop keeps
the native TUI background, while phone width makes it transparent without touching diff colours.
The marked segment carries its fill in --terminal-seg-bg instead of an inline background-color,
so these two rules can win by ordinary cascade order. An inline background-color would need
`!important` to override, which nothing else in this stylesheet does. */
.terminal-mobile-transparent-bg {
background-color: var(--terminal-seg-bg);
}
@media (max-width: 640px) {
.terminal-mobile-transparent-bg {
background-color: transparent;
}
}
/* Native-mirror bright foregrounds (Muse, .adr/0047). A marked span keeps its emitted colour as
the var() fallback and reads --terminal-light-dark-fg only when the app is light: pinned
light, or System following an OS in light. Dark (pinned or System) defines nothing, so the
emitted colour stands untouched. The value is --foreground's light half, written literally to
match the mirror's one-spelling convention (#0a0a0a is oklch(0.145); use-theme.ts re-measures
if it moves). Scoped to pre.terminal-muse so no other surface can inherit the override. */
:root.light pre.terminal-muse .terminal-light-dark-fg {
--terminal-light-dark-fg: #0a0a0a;
}
@media (prefers-color-scheme: light) {
:root:not(.dark) pre.terminal-muse .terminal-light-dark-fg {
--terminal-light-dark-fg: #0a0a0a;
}
}
/* Native-mirror muted chrome (Muse, .adr/0047). Same mechanism as above: muted spans keep the
dark-space grey as their var() fallback and read --terminal-muted-fg only in a light native
mirror. The value is --muted-foreground's light half, written literally (#5d5d5d is oklch(0.48);
use-theme.ts re-measures if it moves) — ~6:1 on the page ground, the parity of what inversion
gives the same spans elsewhere. */
:root.light pre.terminal-muse .terminal-muted {
--terminal-muted-fg: #5d5d5d;
}
@media (prefers-color-scheme: light) {
:root:not(.dark) pre.terminal-muse .terminal-muted {
--terminal-muted-fg: #5d5d5d;
}
}
/* One cell of the mirror, painted rather than typed — lib/cell-glyphs.ts has the whole argument,
and its CELL_FILL table names every `data-cell` value below.
A BLOCK ELEMENT IS PAINTED ON A BAND THAT IS THE ROW. An inline box's own background covers the
font's content area, its ascent plus descent, and that is not the row: at 10px it measured 13px
against a 12.5px row in Chromium on Linux, and sat 0.75px above the row's centre. So the paint is
not the span's background. It is a `::before` box, absolutely positioned with `top` left `auto`,
and an absolutely positioned box that started out inline takes its STATIC POSITION: the top of
the line box it sits in. `height: 1lh` is the row pitch, and `left: 0; right: 0` is the span's
own advance, so the band is exactly one cell: the row's top and bottom in Chromium and WebKit,
with no offset of its own (web/e2e/cell-glyphs.spec.ts reads it off the pixels). Every shape is
then a background layer in percentages of that band.
HALF A PIXEL OVER, at the top only. Two bands that meet exactly are rounded to device pixels one
box at a time, and at device pixel ratio 3 WebKit rounded two stacked rows one pixel APART, a
dark line through a stacked bar. The extra 0.5px reaches up under the row above, which paints
first, and closes that at ratio 2 and up. It goes up and not down because the last row's band
would otherwise poke out of the <pre> and give it half a pixel to scroll. It moves a half
block's edge by 0.25px.
IT MOVES NOTHING. An absolutely positioned box takes no room, and the span sets no width, no
display and no padding, so every row and every column lands where it did. `inline-block` sized
to one cell measured 0.27px short at each end in WebKit and was dropped. The span is a stacking
context so the band can sit under its own text: a selection still shows over a painted cell.
ONE SPAN PER CHARACTER, on purpose. A span that holds a run is split across two lines when the
mirror wraps inside it, and an absolutely positioned child has one box, so the run's second line
would lose its paint and, with the ink emptied, show nothing at all.
THE CHARACTER IS STILL IN HERE, as a text node — find offsets, link offsets, selection and copy
all read it, and none of them may change. `-webkit-text-fill-color` is what stops it being seen:
it empties the INK and leaves `color` alone, so `currentColor` in the paint still resolves to the
segment's own foreground, and .adr/0002's inversion filter treats the paint exactly like text.
`color: transparent` would take the paint with it.
A browser without `lh` (Safari before 16.4) skips all of this and keeps the font's own glyph,
short but visible.
The Powerline four are the exception, and their rule below says why. */
@supports (height: 1lh) {
.cell-glyph {
--ink: linear-gradient(currentColor, currentColor);
-webkit-text-fill-color: transparent;
}
.cell-glyph:not([data-cell^="wedge-"], [data-cell^="round-"]) {
position: relative;
z-index: 0;
}
.cell-glyph:not([data-cell^="wedge-"], [data-cell^="round-"])::before {
content: "";
position: absolute;
left: 0;
right: 0;
height: calc(1lh + 0.5px);
margin-top: -0.5px;
z-index: -1;
pointer-events: none;
background-repeat: no-repeat;
}
/* `<position> / <size>` of each rectangle, in percentages of the band. Every rectangle a block
element draws is flush to an edge, so each position is 0% or 100%. */
.cell-glyph[data-cell="full"]::before { background-image: var(--ink); }
.cell-glyph[data-cell="lower-1"]::before { background: var(--ink) 0 100% / 100% 12.5% no-repeat; }
.cell-glyph[data-cell="lower-2"]::before { background: var(--ink) 0 100% / 100% 25% no-repeat; }
.cell-glyph[data-cell="lower-3"]::before { background: var(--ink) 0 100% / 100% 37.5% no-repeat; }
.cell-glyph[data-cell="lower-4"]::before { background: var(--ink) 0 100% / 100% 50% no-repeat; }
.cell-glyph[data-cell="lower-5"]::before { background: var(--ink) 0 100% / 100% 62.5% no-repeat; }
.cell-glyph[data-cell="lower-6"]::before { background: var(--ink) 0 100% / 100% 75% no-repeat; }
.cell-glyph[data-cell="lower-7"]::before { background: var(--ink) 0 100% / 100% 87.5% no-repeat; }
.cell-glyph[data-cell="upper-1"]::before { background: var(--ink) 0 0 / 100% 12.5% no-repeat; }
.cell-glyph[data-cell="upper-4"]::before { background: var(--ink) 0 0 / 100% 50% no-repeat; }
.cell-glyph[data-cell="left-1"]::before { background: var(--ink) 0 0 / 12.5% 100% no-repeat; }
.cell-glyph[data-cell="left-2"]::before { background: var(--ink) 0 0 / 25% 100% no-repeat; }
.cell-glyph[data-cell="left-3"]::before { background: var(--ink) 0 0 / 37.5% 100% no-repeat; }
.cell-glyph[data-cell="left-4"]::before { background: var(--ink) 0 0 / 50% 100% no-repeat; }
.cell-glyph[data-cell="left-5"]::before { background: var(--ink) 0 0 / 62.5% 100% no-repeat; }
.cell-glyph[data-cell="left-6"]::before { background: var(--ink) 0 0 / 75% 100% no-repeat; }
.cell-glyph[data-cell="left-7"]::before { background: var(--ink) 0 0 / 87.5% 100% no-repeat; }
.cell-glyph[data-cell="right-1"]::before { background: var(--ink) 100% 0 / 12.5% 100% no-repeat; }
.cell-glyph[data-cell="right-4"]::before { background: var(--ink) 100% 0 / 50% 100% no-repeat; }
/* Quadrants: each quarter is half the band each way, in its own corner. */
.cell-glyph[data-cell^="quad-"] {
--ul: var(--ink) 0 0 / 50% 50% no-repeat;
--ur: var(--ink) 100% 0 / 50% 50% no-repeat;
--ll: var(--ink) 0 100% / 50% 50% no-repeat;
--lr: var(--ink) 100% 100% / 50% 50% no-repeat;
}
.cell-glyph[data-cell="quad-ll"]::before { background: var(--ll); }
.cell-glyph[data-cell="quad-lr"]::before { background: var(--lr); }
.cell-glyph[data-cell="quad-ul"]::before { background: var(--ul); }
.cell-glyph[data-cell="quad-ur"]::before { background: var(--ur); }
.cell-glyph[data-cell="quad-ul-ll-lr"]::before { background: var(--ul), var(--ll), var(--lr); }
.cell-glyph[data-cell="quad-ul-lr"]::before { background: var(--ul), var(--lr); }
.cell-glyph[data-cell="quad-ul-ur-ll"]::before { background: var(--ul), var(--ur), var(--ll); }
.cell-glyph[data-cell="quad-ul-ur-lr"]::before { background: var(--ul), var(--ur), var(--lr); }
.cell-glyph[data-cell="quad-ur-ll"]::before { background: var(--ur), var(--ll); }
.cell-glyph[data-cell="quad-ur-ll-lr"]::before { background: var(--ur), var(--ll), var(--lr); }
/* Powerline: NOT the row, on purpose. These four exist to join a segment background beside them,
a prompt pill's middle, and a segment background covers the content area, not the row. So they
paint the span's own background, the content area, and meet the pill on the same two pixels in
any face. Painted to the row they would step out of the pill wherever the two differ. A wedge
is two half layers whose 50% stop lies on the layer's own diagonal, which IS the wedge's edge;
49.8% leaves the stop a sliver to antialias in. A cap is a full cell rounded all the way out
on one side, the half-ellipse the font draws. */
.cell-glyph[data-cell="wedge-right"] {
background:
linear-gradient(to top right, currentColor 49.8%, transparent 50%) 0 0 / 100% 50% no-repeat,
linear-gradient(to bottom right, currentColor 49.8%, transparent 50%) 0 100% / 100% 50% no-repeat;
}
.cell-glyph[data-cell="wedge-left"] {
background:
linear-gradient(to top left, currentColor 49.8%, transparent 50%) 0 0 / 100% 50% no-repeat,
linear-gradient(to bottom left, currentColor 49.8%, transparent 50%) 0 100% / 100% 50% no-repeat;
}
.cell-glyph[data-cell="round-right"] {
background: var(--ink);
border-radius: 0 100% 100% 0 / 0 50% 50% 0;
}
.cell-glyph[data-cell="round-left"] {
background: var(--ink);
border-radius: 100% 0 0 100% / 50% 0 0 50%;
}
}
/* `dark:` must fire for BOTH an explicitly-pinned dark root and a system-dark root the user hasn't
pinned to light — otherwise the handful of literal `dark:` utilities silently stop working for
everyone on System (the default). Token-driven colors don't need this; these are the literals. */
@custom-variant dark {
&:is(.dark *) {
@slot;
}
@media (prefers-color-scheme: dark) {
&:is(:root:not(.light) *) {
@slot;
}
}
}
/* ── Theme tokens ───────────────────────────────────────────────────────────────────────────────
Every themed value is a single `light-dark(<light>, <dark>)` declaration, resolved by the
element's computed `color-scheme`. That is why there is ONE token block here and not two: the
media-query-plus-override shape would have to restate the whole dark palette, and nothing in CSS
can share a declaration block across an `@media` boundary.
`color-scheme` therefore does real work and is not decoration — it also makes the browser paint
native UI (scrollbars, form controls, the caret, the iOS keyboard, <select> menus) to match.
:root → `light dark`, i.e. follow the OS. The default; no class on <html>.
:root.light → pinned light, set by useTheme / theme-init.js
:root.dark → pinned dark, ditto
Pinning is done with a class rather than a `color-scheme` media query so first paint can be fixed
by public/theme-init.js before the bundle loads. */
:root {
color-scheme: light dark;
/* 2px, not the stock 0.65rem, and it does not ramp. The radius descends from the mark: the mark
holds a disc and a traced line, and it holds no stadium and no soft-cornered box anywhere. A
corner is either a full circle (width == height — a dot, an avatar, the switch thumb) or it is
effectively square. 2px is the smallest radius that still reads as intentional rather than as a
rasterizer artefact, so it is the floor the whole surface sits on. The four derived steps below
are hard-set to the same 2px: the stock shadcn calc() ramp subtracts up to 4px, which at this
base computes negative and is invalid. */
--radius: 2px;
/* Light deviates from stock shadcn in two places, both measured rather than taste:
--background is a step off white (stock has background == card == popover == pure white, so the
entire dashboard hierarchy rests on one 1.26:1 hairline), and --muted-foreground is darkened
for headroom (see below). oklch(0.97) rasterizes to rgb(245,245,245), which is exactly the
terminal mirror's inverted background — so the mirror stops showing a seam against the page. */
--background: light-dark(oklch(0.97 0 0), oklch(0.145 0 0));
--foreground: light-dark(oklch(0.145 0 0), oklch(0.985 0 0));
--card: light-dark(oklch(1 0 0), oklch(0.205 0 0));
--card-foreground: light-dark(oklch(0.145 0 0), oklch(0.985 0 0));
/* THE GROUND UNDER THE THUMB — the composer's chrome block, and nothing else. Chrome is normally
the page colour separated by a rule (see --muted below), and that rule holds everywhere the
chrome sits on the PAGE. It cannot hold here: this block sits on the terminal mirror, and in
dark --background IS the mirror's fill (components/mirror-space.ts), so page-coloured chrome
against it is invisible and the grab handle inside it stops reading as a control at all.
It needs a fill, and the fill it had was --muted: rgb(38) in dark, a bright slab under a
rgb(10) terminal, which the operator read as too light. This is the same value as --card in
dark — one raised-chrome surface app-wide, shared with the sheets — and the same value --muted
already had in light, where --card is pure white and would land 1.04:1 against the inverted
mirror. One name, because it answers one question: what does chrome stand on when it cannot
stand on the page. */
--chrome: light-dark(oklch(0.94 0 0), oklch(0.205 0 0));
/* Pinned to --card, not a value of its own. `bg-popover` / `text-popover-foreground` appear ZERO
times in src/ — Collie's floating layer is the BottomSheet (ui/sheet.tsx), which is
`bg-background` over a `bg-black/50` scrim. The pair is kept rather than deleted only so a
future `npx shadcn add` of a Popover/Tooltip primitive lands on a Collie surface instead of
stock white; it is an alias, and there is no third paper behind it. */
--popover: var(--card);
--popover-foreground: var(--card-foreground);
--primary: light-dark(oklch(0.205 0 0), oklch(0.922 0 0));
--primary-foreground: light-dark(oklch(0.985 0 0), oklch(0.205 0 0));
/* The "this control is ON" tint — a very light sky. Its own token pair rather than reusing
--secondary because the two say different things: secondary is a quieter surface, this is a
STATE, and the row needs the difference legible at a glance (an open dock, an armed mode). Kept
low-chroma so a row of grey controls doesn't turn into a traffic light when two are on.
The light half is tuned by CHROMA, not luminance: at "very light sky" lightness the tint is only
~1.2:1 against the page by WCAG, and no value that still reads as light sky does better — what
makes it visible is that it is the one saturated thing in a neutral row. Don't "fix" that number
by darkening it into a mid blue. Text on the tint is what has to clear a contrast bar, and does:
7.3:1 light, 9.2:1 dark. */
--control-on: light-dark(oklch(0.9 0.06 235), oklch(0.32 0.05 240));
--control-on-foreground: light-dark(
oklch(0.38 0.12 245),
oklch(0.89 0.06 235)
);
--secondary: light-dark(oklch(0.94 0 0), oklch(0.269 0 0));
--secondary-foreground: light-dark(oklch(0.205 0 0), oklch(0.985 0 0));
/* The INERT fill — a well, a disabled track, a quiet chip on a card. Value unchanged (rgb 235
light / 38 dark), but its old job is gone: it used to be the chrome band behind the header,
"a step BELOW --background so the band still reads as a distinct surface". The band existed
because the line it competed with was not a line — `border-border/60` composites to 1.09:1
against the page in light and 1.16:1 in dark, i.e. a rumour. --rule below is that line drawn
properly, and chrome is now the page colour (app-header.tsx, settings.tsx, pack.tsx).
Do NOT put --muted back behind chrome: on the pane screen it re-opens the seam --background's
0.97 was chosen to close, and it is the worst ground a status chip has ever landed on. */
--muted: light-dark(oklch(0.94 0 0), oklch(0.269 0 0));
/* Light was oklch(0.556) — 4.74:1 on white, i.e. zero headroom, so every /70, /80 and opacity-60
derivative in the app fell under 3:1 while its dark counterpart passed. Darkened to buy that
headroom back; the alpha modifiers on small text were dropped as well, since no token value
rescues a /70. */
--muted-foreground: light-dark(oklch(0.48 0 0), oklch(0.708 0 0));
/* Light --accent must be a real step off --background, not stock shadcn's 0.97. Stock is right
only while background is pure white; 0.18.0 deliberately moved background to 0.97 (see above),
which silently made accent === background — so every "this is the current one" highlight
(`bg-accent` in the pane switcher and session switcher) and every `hover:bg-accent` rendered
as nothing at all in light mode. It sits a step BELOW --muted (0.94), not between it and
--background: the switcher needs three separable values — resting 0.97, hover (bg-muted/60)
~0.94, active 0.92 — and 0.94 would have collided the last two. */
--accent: light-dark(oklch(0.92 0 0), oklch(0.269 0 0));
--accent-foreground: light-dark(oklch(0.205 0 0), oklch(0.985 0 0));
--destructive: light-dark(
oklch(0.577 0.245 27.325),
oklch(0.704 0.191 22.216)
);
--destructive-foreground: light-dark(oklch(0.985 0 0), oklch(0.985 0 0));
--border: light-dark(oklch(0.922 0 0), oklch(1 0 0 / 12%));
--input: light-dark(oklch(0.922 0 0), oklch(1 0 0 / 15%));
/* The STRUCTURAL line, and deliberately stronger than --border. --border draws one component's
edge; --rule draws the cut between two regions of chrome — the header's bottom edge, a strip
separator, the composer's top edge — where the whole job is to separate chrome from page
without a fill.
Measured against --background, which is the only ground chrome sits on:
border-border/60 1.09:1 light · 1.16:1 dark ← what the header used, invisible
border-border 1.16:1 light · 1.26:1 dark
--rule 1.34:1 light · 2.06:1 dark
rgb(214) on rgb(245) light; white 24% over rgb(10) = rgb(69) dark. At 3x DPR on a phone that
is a crisp 1px cut, which is the mark's own vocabulary — a cut, not a wash. */
--rule: light-dark(oklch(0.875 0 0), oklch(1 0 0 / 24%));
/* Light is darkened off stock shadcn (0.708 → 0.62): at 0.708 the focus ring measures 2.58:1 on
white, under the 3:1 WCAG 1.4.11 floor for a non-text indicator — and components draw it at
`ring-ring/50`, which halves that again. On an app whose buttons type into a live shell, a focus
ring you can't locate is a real problem. Still worth an on-device look: even at 0.62 the /50
variant is marginal. */
--ring: light-dark(oklch(0.62 0 0), oklch(0.556 0 0));
/* Agent lifecycle status palette — the app's primary at-a-glance signal, so both halves are
tuned against their own ground rather than one set being reused. The dark values glow on
near-black; the light values are picked to clear 4.5:1 on white (measured, not estimated —
blocked 5.4:1, working 4.8:1, done 4.6:1, idle 5.5:1, unknown 6.5:1).
These carry text AND fills (`text-status-working`, `bg-status-blocked` dots). Tuning for text
makes the dots darker; if that costs the dashboard its glance-ability outdoors, the fallback is
splitting fill and text into separate tokens.
Measure the CHIP, never the dot: the chip is `bg-status-X/15` under `text-status-X`, so the
translucent wash lifts the ground toward the ink and the ratio is always worse than the solid
text. /15 is the strict case and the one the table below uses; status-badge.tsx draws its chip
at /10, which is ~0.4 kinder everywhere, so anything that passes here passes there too. Tuned against plain white these read 5.4/4.8/4.6 and failed on the chip; tuned against
the chip on a white card they read 4.90/4.58/4.46 and still failed, because the ground varies.
The historic worst ground was the header band, `bg-muted` rgb(235) — that band is GONE (see
--muted). Chips now land on --background or --card and on nothing else. The table below is
RASTERISED, not arithmetic: each cell was rendered by the browser at DPR 1 and the pixel read
back, so the oklab `color-mix` Tailwind emits for `/15` is measured rather than modelled. Plain
sRGB alpha arithmetic agrees to within 0.08 and always in the pessimistic direction.
ground blocked working done idle unknown info
--card 255 5.71 5.75 5.39 5.96 6.40 5.19 (light)
--background 245 5.23 5.28 4.95 5.47 5.88 4.77 (light, the only chrome ground)
--muted 235 4.83 4.89 4.58 5.06 5.44 *4.41 (light, the old header band)
--accent 228 4.55 4.60 *4.31 4.77 5.12 *4.16 (light, NOT A PERMITTED GROUND)
--background 10 5.59 8.67 7.37 6.79 5.75 8.41 (dark)
--card 23 4.90 7.47 6.39 5.89 5.01 7.28 (dark)
--muted 38 *4.13 6.16 5.29 4.87 *4.16 6.02 (dark, the old header band)
--accent 38 *4.13 6.16 5.29 4.87 *4.16 6.02 (dark, NOT A PERMITTED GROUND)
Every cell on --background and --card clears 4.5. The starred cells are why the two fills are
not permitted grounds, and they are recorded so nobody re-derives them the expensive way.
Both of the failures this table used to record are now CLOSED, and the fixes were of two
different kinds — keep the distinction, because it is the rule for the next one:
· dark `unknown` was 4.35 on --background and 3.82 on --card. No ground could reach it, so
the TOKEN moved. See --status-idle / --status-unknown below.
· the switcher active row was `bg-accent` (rgb 228 light / 38 dark) and it CONTAINS status
count pills: blocked 4.55 / working 4.60 light — hairline — and blocked 4.13 dark, an
outright fail. The GROUND moved: the fill is gone and the row is marked by a 2px inset rail
in --primary (session-switcher.tsx, server-switcher.tsx), so the pills land on the sheet's
--background at 5.23 / 5.28 light and 5.59 / 8.67 dark.
A chip belongs on --background or --card and on nothing else. */
--status-blocked: light-dark(oklch(0.46 0.2 25), oklch(0.7 0.2 24));
--status-working: light-dark(oklch(0.46 0.12 72), oklch(0.82 0.15 82));
--status-done: light-dark(oklch(0.45 0.14 152), oklch(0.74 0.16 152));
/* The two quiet ends of the palette, and the only pair in it tuned as a PAIR. Light halves are
untouched (chip on --background: idle 5.47, unknown 5.88).
Both dark halves were raised, and both had to move together. Dark `unknown` was oklch(0.6 0.02)
= rgb(119,129,140): its chip measured 4.35 on --background and 3.82 on --card, so it failed the
4.5 floor on every ground it is allowed to sit on and no ground change could reach it. Clearing
--card (the binding ground, since the wash lifts less over a lighter paper) needs L >= 0.645 for
a bare pass; 0.68 was taken instead so the pass has ~0.46 of headroom rather than a hairline.
Raising `unknown` to 0.68 would have put it ABOVE `idle` at 0.65 and inverted the pair, so
`idle` went up to 0.73 and the 0.05 L gap between them is preserved exactly.
The chroma went 0.02 -> 0.03 in the dark halves only, and that is the load-bearing part: at
L 0.68/0.73 these values sit on either side of --muted-foreground (0.708, rgb 161,161,161), so
lightness can no longer separate them from ordinary quiet text and the tint has to. Measured as
rgb distance, the three pairs now sit in one band — idle/unknown 26.6 (was 26.0),
idle/muted-fg 27.5 (was 32.5), unknown/muted-fg 25.2 — i.e. no pair is closer than the
idle/unknown pair this file already treats as far enough apart. 0.03 is still the lowest chroma
in the app; --control-on is 0.05 and every other status hue is 0.15 or more, so neither of these
reads as a signal blue.
Dark chips, --background / --card: idle 6.79 / 5.89 unknown 5.75 / 5.01. New rgb values are
idle 154,170,186 and unknown 139,154,171. */
--status-idle: light-dark(oklch(0.45 0.02 250), oklch(0.73 0.03 250));
--status-unknown: light-dark(oklch(0.43 0.02 250), oklch(0.68 0.03 250));
/* Calm-info sky blue — same shape as the lifecycle palette above (dark brighter to glow on
near-black). It is the palette's tightest light value: on the old rgb(235) header band its chip
measured 4.41 — under the floor the block above claims. Chrome is the page now, so the one
surface that uses it (alpha-bar.tsx, inside the header) reads 4.77. It has ~0.27 of headroom
and no more; do not lighten it, and do not put it back on a fill. Same shape as the lifecycle
values but a distinct hue (230, cool sky) so it never gets confused with `status-working`'s
amber "needs attention" signal. Used by
surfaces that are informational, not a status: currently just the prerelease strip
(alpha-bar.tsx). */
--status-info: light-dark(oklch(0.46 0.14 230), oklch(0.8 0.15 230));
/* Syntax colour for the Changes view's diff (components/changes-view.tsx, lib/diff-highlight.ts,
ADR 0065 rule 7). Six inks, all colour and nothing else: no weight, no italic, so a coloured
line keeps the plain line's glyphs and line boxes. Kinds without an ink (names, punctuation,
JSX text) keep --foreground.
Hues keep off the two row tints: no green (the added-row wash, done 152) and no red (the
deleted-row wash, blocked 25). Light halves sit at L 0.45-0.50 like the status palette, dark
halves at 0.76-0.80 like its glow. Comments are --muted-foreground's values, grey on purpose.
Contrast, computed in sRGB with the /12 row tint mixed over --background (plain alpha is the
pessimistic side of the oklab mix Tailwind emits), ground: page / card / added / deleted:
keyword light 6.17 6.73 5.15 4.92 dark 8.60 7.78 7.29 7.60
string light 6.82 7.43 5.69 5.44 dark 10.7 9.72 9.10 9.50
constant light 6.26 6.83 5.23 5.00 dark 10.3 9.33 8.74 9.12
property light 6.15 6.70 5.13 4.90 dark 11.0 9.94 9.31 9.71
entity light 6.55 7.15 5.47 5.23 dark 8.84 8.00 7.49 7.82
comment light 5.99 6.54 5.00 4.78 dark 7.63 6.91 6.47 6.75
Every cell clears 4.5. The tightest is light on the deleted row; darken there, never lighten. */
--syntax-keyword: light-dark(oklch(0.5 0.2 350), oklch(0.76 0.15 350));
--syntax-string: light-dark(oklch(0.45 0.13 250), oklch(0.8 0.1 240));
--syntax-constant: light-dark(oklch(0.48 0.13 55), oklch(0.8 0.12 60));
--syntax-property: light-dark(oklch(0.46 0.09 200), oklch(0.8 0.09 195));
--syntax-entity: light-dark(oklch(0.48 0.18 300), oklch(0.76 0.13 300));
--syntax-comment: light-dark(oklch(0.48 0 0), oklch(0.708 0 0));
/* ── Per-host identity palette (components/host-chip.tsx, lib/hosts.ts) ──────────────────────
Ten hues, `--host-0` … `--host-9`, handed to machines by lib/hosts.ts `hostSlot`. They are an
IDENTITY, never a state: nothing about a host's health, version or reachability is encoded
here, and the machine's NAME is always rendered beside the tint (WCAG 1.4.1 — colour is the
second encoding, never the only one). On a solo collie no host wears one at all.
WHY THESE TEN HUES, AND NOT TAILWIND'S SHADE RAMP. Four bands of the wheel are already spoken
for by the status palette above and a host tint must never be read as a status: blocked 24-27,
working 72-82, done 149-152, info 230. What is left is ten hues at ~20-90 degrees of
separation — orange 41, lime 132, teal 185, cyan 221, sky 241, blue 262, indigo 280,
purple 302, fuchsia 325, pink 352 — and the SLOT ORDER below interleaves them, so the linear
probe in `hostSlot` hands neighbouring slots hues from opposite sides of the wheel.
Two hues sit near a status and are kept deliberately: cyan (9 off `info`) and sky (11 off it).
`--status-info` is drawn in two places, the prerelease strip in the header (alpha-bar.tsx)
and the cache chip's cold glyph (cache-chip.tsx), and never beside a host mark. Everything
nearer a status than that was dropped: rose and red (blocked), amber and yellow (working),
green and emerald (done).
The light halves are NOT Tailwind's `-600` shades. Tailwind's 600s read 2.4-4.7:1, i.e. most of
the ramp fails 4.5 outright, and its 700s and 800s fail too for the low-chroma hues. So the
light half is tuned the way the status palette is: L 0.46, the hue's maximum in-gamut chroma
capped at 0.20. The dark half IS Tailwind's `-400` L/C at the same hue — the ramp's dark end
glows correctly on near-black and needed no work.
The tint is applied to the GLYPH ONLY (ui/address-tag.tsx, host-chip.tsx) — the tag's border,
background and name stay the literal untinted `border-border bg-muted/60 text-muted-foreground`
always; only the leading icon inside it wears `text-host-N`. A wash across the whole pill read
as too much and was dropped. The table below is a RECORD of the wash's rasterised chip contrast
from when `bg-host-N/15` under `text-host-N` was still applied to the whole tag; it no longer
drives what ships, since `text-host-N` on a small glyph over `--muted/60` clears every one of
these grounds by a wider margin than the wash did:
slot 0 cyan 5.07/5.48 light 8.39/7.27 dark
slot 1 orange 5.46/5.90 6.47/5.63
slot 2 purple 5.71/6.16 5.89/5.13
slot 3 lime 5.03/5.44 9.74/8.38
slot 4 pink 5.47/5.96 6.01/5.25
slot 5 teal 4.96/5.37 8.43/7.22
slot 6 blue 5.40/5.86 6.09/5.30
slot 7 fuchsia 5.65/6.12 6.36/5.55
slot 8 sky 5.11/5.55 7.10/6.17
slot 9 indigo 5.58/6.08 5.34/4.67 ← the palette's tightest cell
Every cell clears 4.5 on both permitted grounds. `--muted` and `--accent` are not permitted
grounds here either, for the reason the status block states. */
--host-0: light-dark(oklch(0.46 0.085 221), oklch(0.789 0.154 221));
--host-1: light-dark(oklch(0.46 0.143 41), oklch(0.75 0.183 41));
--host-2: light-dark(oklch(0.46 0.2 302), oklch(0.714 0.203 302));
--host-3: light-dark(oklch(0.46 0.129 132), oklch(0.841 0.238 132));
--host-4: light-dark(oklch(0.46 0.192 352), oklch(0.718 0.202 352));
--host-5: light-dark(oklch(0.46 0.082 185), oklch(0.777 0.152 185));
--host-6: light-dark(oklch(0.46 0.2 262), oklch(0.707 0.165 262));
--host-7: light-dark(oklch(0.46 0.2 325), oklch(0.74 0.238 325));
--host-8: light-dark(oklch(0.46 0.108 241), oklch(0.746 0.16 241));
--host-9: light-dark(oklch(0.46 0.2 280), oklch(0.673 0.182 280));
/* ── Terminal palette ─────────────────────────────────────────────────────────────────────────
The 16 indexed ANSI slots, emitted by lib/ansi.ts as var(--ansi-N).
ONE set, not two, and it is the dark one — VS Code's Dark+ terminal palette. The mirror renders
in dark space under every theme and the light theme inverts it wholesale
(components/ansi-output.tsx, .adr/0002), so a light palette here would be applied and then
inverted into nonsense. These sit next to the truecolor an agent emits, which no palette can
re-theme, so they must share its assumptions: authored for a dark ground. */
--ansi-0: #000000;
--ansi-1: #cd3131;
--ansi-2: #0dbc79;
--ansi-3: #e5e510;
--ansi-4: #2472c8;
--ansi-5: #bc3fbc;
--ansi-6: #11a8cd;
--ansi-7: #e5e5e5;
--ansi-8: #666666;
--ansi-9: #f14c4c;
--ansi-10: #23d18b;
--ansi-11: #f5f543;
--ansi-12: #3b8eea;
--ansi-13: #d670d6;
--ansi-14: #29b8db;
--ansi-15: #ffffff;
}
/* Explicit pins. Class-based so theme-init.js can set them before first paint. */
:root.light {
color-scheme: light;
}
:root.dark {
color-scheme: dark;
}
@theme inline {
/* One radius, four names. There is no ramp to walk: sm/md/lg/xl all resolve to the same 2px, so
`rounded-md` and `rounded-xl` are the same corner and no component can drift rounder than its
neighbour by picking a bigger step. Written out rather than left as calc(): at --radius: 2px
the stock `- 2px` and `- 4px` forms compute 0 and -2px, and a negative radius is invalid. */
--radius-sm: 2px;
--radius-md: 2px;
--radius-lg: 2px;
--radius-xl: 2px;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-chrome: var(--chrome);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-control-on: var(--control-on);
--color-control-on-foreground: var(--control-on-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-rule: var(--rule);
--color-ring: var(--ring);
--color-status-blocked: var(--status-blocked);
--color-status-working: var(--status-working);
--color-status-done: var(--status-done);
--color-syntax-keyword: var(--syntax-keyword);
--color-syntax-string: var(--syntax-string);
--color-syntax-constant: var(--syntax-constant);
--color-syntax-property: var(--syntax-property);
--color-syntax-entity: var(--syntax-entity);
--color-syntax-comment: var(--syntax-comment);
--color-status-idle: var(--status-idle);
--color-status-unknown: var(--status-unknown);
--color-status-info: var(--status-info);
--color-host-0: var(--host-0);
--color-host-1: var(--host-1);
--color-host-2: var(--host-2);
--color-host-3: var(--host-3);
--color-host-4: var(--host-4);
--color-host-5: var(--host-5);
--color-host-6: var(--host-6);
--color-host-7: var(--host-7);
--color-host-8: var(--host-8);
--color-host-9: var(--host-9);
--font-mono:
"Nerd Font Symbols", "JetBrains Mono", "Cascadia Mono", "Cascadia Code",
"Roboto Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
/* The UI typeface — CHROME ONLY, and the DEFAULT one. Which face this actually is became a
per-device setting in ADR 0033; this is the stack a device that has never touched the setting
gets, which is why it is still spelled here and not behind a class. The `:root.font-*` blocks
below hold the others. See the @font-face block for what "chrome only" means and why the
stand-in family in the middle of the stack is not optional. */
--font-sans:
"Aldrich", "Aldrich Fallback", ui-sans-serif, system-ui, -apple-system,
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
/* The CONTENT stack: exactly what the app rendered in before the UI typeface arrived. Anything an
agent or a machine authored wears this, so the custom face never dresses someone else's words
(see the boundary note below). It is a plain system stack on purpose — it ships no bytes. */
--font-content:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
}
/* Nerd Font symbols, bundled — the only webfont Collie ships.
*
* WHY. Agent prompts and status lines are full of Nerd Font glyphs (Powerline separators, devicons,
* Material icons: OMP's `symbolPreset: nerd`, herdr's own output). Desktop browsers can resolve them
* from a locally installed Nerd Font; mobile browsers cannot, so on the device Collie is actually
* FOR, every one of them rendered as a tofu box (#70). Nothing but shipping the glyphs fixes that.
*
* WHY IT'S FREE FOR PEOPLE WHO DON'T NEED IT. `unicode-range` restricts each face to private-use
* codepoints, so the browser fetches a file only once a glyph in that range is actually painted — a
* herd whose agents emit no Nerd Font glyphs downloads zero bytes. The split at the plane boundary
* keeps that promise sharp: a Powerline-only prompt pulls the 626 KB BMP face and never touches the
* 492 KB Material one. Both are kept OUT of the service-worker precache for the same reason (see
* vite.config.ts) and cached on first use instead (src/sw.ts).
*
* It comes first in `--font-mono` and that is safe precisely because of `unicode-range`: it can
* never be consulted for a letter, digit or box-drawing character, and CSS picks the "first
* available font" — the one whose metrics set the line — from the faces that cover U+0020, which
* this one does not. It carries symbols and nothing else, so the real monospace face still decides
* how text looks.
*
* EXPECTED, NOT A BUG. A symbol takes this font's advance width, not the surrounding monospace's, so
* in the pane mirror an icon nudges the columns to its right by a fraction of a cell. Fixing that
* needs a terminal emulator, which Collie does not run (ADR 0008) — a glyph you can read beats a
* column you can't.
*
* The files are subsets of Symbols Nerd Font Mono v3.5.0 (MIT — public/fonts/LICENSE.txt), built by
* scripts/build-nerd-font.sh. Regenerating means a new version in the filename: public/ assets are
* unhashed and the SW caches them forever by URL. */
@font-face {
font-family: "Nerd Font Symbols";
src: url("/fonts/nerd-symbols-3.5.0-pua.woff2") format("woff2");
font-display: swap;
unicode-range: U+E000-F8FF;
}
@font-face {
font-family: "Nerd Font Symbols";
src: url("/fonts/nerd-symbols-3.5.0-spua.woff2") format("woff2");
font-display: swap;
unicode-range: U+F0000-F1AFF;
}
/* The UI typefaces — Space Grotesk (the default) and Aldrich, both bundled. The app's own voice,
* and NOTHING ELSE'S.
*
* WHAT IT DRESSES. Chrome: headers, section labels, buttons, settings rows, banners, counts, the
* wordmark. WHICH face does that is the reader's choice, per device: the Typeface card in Settings
* writes `collie:design:v1` and public/theme-init.js turns it into a root class before first paint
* (ADR 0033). This used to say "the maker's choice and there is no setting for it"; that decision
* fell, and the ADR records why. The Terminal font card below it is a DIFFERENT setting — it
* configures --font-mono, which nothing in this block touches.
*
* WHAT IT MUST NEVER DRESS. Anything an agent or a machine authored: the pane mirror, the
* transcript, agent prose and markdown, code, command text, file paths, ANSI. Two mechanisms hold
* that line, and both must stay:
* 1. `font-mono` — every verbatim terminal surface already carries it, so it resolves through
* --font-mono and never sees this face. The colour half of the same boundary is
* components/mirror-space.ts:33-34 (MIRROR_SPACE / MIRROR_INVERT); read its header first.
* 2. `font-content` (--font-content above) — for agent-authored text that is NOT verbatim
* terminal output and therefore is not monospaced: rendered markdown, and the labels the
* interactive blocks lift out of a dialog. mirror-space.ts says those blocks are "siblings of
* the mirror, not children" and keep app theming — true for colour, and exactly why they need
* this second mechanism for family. A new surface that prints an agent's words takes one of
* the two. If you cannot tell whether a surface is chrome or content, it is content.
*
* WHY THIS FACE. The mark is a line-art collie head in an ellipse with monotone beads: one stroke,
* constant curvature, mitred corners (collie-brand/src/bead.ts). Space Grotesk is the candidate
* drawn from the same idea — geometric skeleton, monotone stroke, cut terminals — so the wordmark
* at 18px reads as one drawing with the mark beside it. Its cost is the small end: x-height is
* 486/1000, the lowest of the three faces measured, so 11px uppercase labels and the 13px tier are
* where it has to be judged. The other two candidates (IBM Plex Sans 3.201, Geist 1.800) are on
* disk in public/fonts/ and wired into the playground's "UI typeface" card behind a live switcher —
* that card is where a candidate is AUDITIONED before anyone argues for shipping it.
*
* WEIGHT. 27 KB, subset to Latin + Latin-Ext-A + the punctuation this app actually prints (·, —, …,
* ‹›, arrows, ×, ✓) and instanced to wght 400–700. Compare 641 KB / 504 KB for the two Nerd Font
* faces — those are lazy behind `unicode-range`; this one is on the critical path, which is why it
* had to be this small. `tnum` is kept in the subset: every count in the app is `tabular-nums`.
* Rebuild with scripts/build-ui-font.sh; the version is in the filename because public/ assets are
* unhashed and the SW caches them forever by URL (lib/sw-routes.ts FONT_URLS).
*
* NO LAYOUT SHIFT — the part that is not negotiable. `font-display: swap` paints immediately in a
* fallback, so the only question is whether the swap MOVES anything. It does not, because the two
* families below are the local system face re-declared with Space Grotesk's own metrics:
* `size-adjust` matches the average advance width over the app's real charset, and the
* ascent/descent overrides restore the line box after that scaling. A glyph changes shape at swap;
* no box changes size. index.html preloads the DEFAULT face's file (Aldrich, since the default
* moved there), so on a warm tailnet its swap usually never happens at all. Measured in Chrome by forcing the swap with an observer running: see the
* stand-in's own comment below for what it is matching and why one table covers every platform.
*
* Space Grotesk is SIL OFL 1.1 — public/fonts/LICENSE-space-grotesk.txt. The two other candidates
* ship their licences beside it. */
@font-face {
font-family: "Space Grotesk";
src: url("/fonts/ui-space-grotesk-2.000-latin.woff2") format("woff2");
font-weight: 400 700;
font-style: normal;
font-display: swap;
}
/* The metric-matched stand-in. No `src: url()` — this IS the local system face, resized, so it
costs nothing and is available at the first byte of CSS. `size-adjust` is the ratio of Space
Grotesk's total advance width over a line of the app's own copy to the same line in the local
face; the ascent/descent overrides then restore the line box after that scaling. Together they
mean the swap changes glyph SHAPES and nothing else: same line breaks, same line boxes, same
block heights. Recomputed by scripts/build-ui-font.sh — do not hand-edit.
The `local()` list is deliberately one metric family: Arial and its clones (Helvetica, Liberation
Sans, Arimo) plus Roboto, which measures within 0.3% of Arial on that same line, so one override
table serves both the desktop and the Android case. Noto Sans is NOT in the list — it is ~5%
wider and would defeat the whole mechanism. If none of them resolve the family is skipped and the
stack falls through to the plain system entries, which is the behaviour this app had before the
face existed. */
@font-face {
font-family: "Space Grotesk Fallback";
src:
local("Arial"), local("Helvetica Neue"), local("Helvetica"),
local("Liberation Sans"), local("Arimo"), local("Roboto");
size-adjust: 107.53%;
ascent-override: 91.51%;
descent-override: 27.16%;
line-gap-override: 0%;
}
/* Aldrich — the second SHIPPED face, and the one a reader picks deliberately. Built by the same
* scripts/build-ui-font.sh run, subset to the same ranges, 8 KB.
*
* ONE WEIGHT, AND NO FAKE SECOND ONE. Aldrich is drawn at 400 and upstream ships nothing else, so
* `font-weight: 400` is the honest declaration. The app asks for 500 and 600 in a hundred places,
* and the engine used to answer every one of them by smearing the 400 outlines. MEASURED, that
* loses: Aldrich's counters are tight to begin with, and synthesis closes them — 14px sidebar text
* smears far worse than a 24px heading, so the damage lands hardest exactly where the app does most
* of its reading. `font-synthesis-weight: none` in the base layer below turns it off for every
* face, and a survey confirmed no UI here uses weight as the SOLE separator between two states, so
* nothing loses its only cue. Bold under Aldrich now renders at Aldrich's one real weight.
*
* That is still not a licence to write `400 700` here. A RANGE would claim weights this file does
* not have, which is a lie to the engine about the font's contents rather than a decision about
* rendering — the suppression belongs in the base layer, where it says what it means.
*
* Aldrich is SIL OFL 1.1 — public/fonts/LICENSE-aldrich.txt. */
@font-face {
font-family: "Aldrich";
src: url("/fonts/ui-aldrich-1.002-latin.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Aldrich's own metric-matched stand-in, on exactly the terms Space Grotesk's has above: no
`src: url()`, the same one metric family, and every number recomputed by scripts/build-ui-font.sh
rather than hand-picked. Aldrich's line box is a good deal shallower than Space Grotesk's, which
is why it needs its OWN table and cannot borrow the one above — sharing it would reintroduce on
this face precisely the shift the mechanism exists to remove. */
@font-face {
font-family: "Aldrich Fallback";
src:
local("Arial"), local("Helvetica Neue"), local("Helvetica"),
local("Liberation Sans"), local("Arimo"), local("Roboto");
size-adjust: 113.37%;
ascent-override: 63.53%;
descent-override: 18.52%;
line-gap-override: 4.22%;
}
/* ── The typeface setting: CSS owns every stack, JavaScript only swaps a class ───────────────────
*
* THE WHOLE MECHANISM IS THESE THREE BLOCKS. `public/theme-init.js` reads `collie:design:v1` before
* first paint and adds ONE class to <html>; `lib/design.ts` swaps it at runtime. Neither ever holds
* a font list, sets a family, or writes a custom property — because a font stack in JavaScript is a
* second source of truth for something this file already states, and the two would drift.
*
* The DEFAULT (Aldrich) is deliberately NOT a class: no class means the `--font-sans` in the
* @theme block above, which is the stack index.html preloads and the boot splash re-declares. A
* device that never opens the setting therefore has no JavaScript in its first-paint path at all.
*
* Each block sets BOTH properties. `--font-sans` is what `body` resolves through; Tailwind's
* `--default-font-family` is what preflight hands to form controls, which would otherwise keep the
* default face while everything around them changed.
*
* Specificity, since it is load-bearing: `:root.font-*` is (0,2,0) and unlayered, and the @theme
* block emits (0,1,0) inside Tailwind's `theme` layer. These win on both counts. */
:root.font-system {
/* No webfont, no stand-in, no bytes: the phone's own interface face. This is the same stack
--font-content uses, spelled out rather than aliased — the two are equal today by coincidence
of both wanting "the system face", not because one is defined as the other. */
--font-sans:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
--default-font-family: var(--font-sans);
}
:root.font-grotesk {
--font-sans:
"Space Grotesk", "Space Grotesk Fallback", ui-sans-serif, system-ui,
-apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--default-font-family: var(--font-sans);
}
/* An operator's own face (theme.toml, ADR 0033). The FAMILY NAME is the one thing this file cannot
know, so `lib/operator-fonts.ts` injects a <style> element that sets `--font-operator-family` and
declares the matching `@font-face` — one validated name and one validated URL, nothing else. The
stack around it still lives here.
*
* THE `var()` FALLBACK IS THE OFFLINE STORY, and it is not decoration. A device set to an operator
* face that cannot be resolved — first paint before /api/config answers, offline, or the operator
* deleted the row — has no `--font-operator-family`, so the stack starts at "Space Grotesk": the
* DEFAULT SHIPPED FACE, never a bare `sans-serif`. Aldrich is named a second time after the
* var() so that a resolved-but-unfetchable face lands there too rather than on the metric twin,
* which is Arial wearing Aldrich's measurements and not the same thing at all.
*
* The preference is NOT cleared when this happens. A phone that is briefly offline must not
* silently forget a choice it will be able to honour again in a minute. */
:root.font-operator {
--font-sans:
var(--font-operator-family, "Aldrich"), "Aldrich", "Aldrich Fallback",
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--default-font-family: var(--font-sans);
}
/* THE APP'S HEIGHT, as one token. `100dvh` everywhere but one place: an iOS home-screen launch, where
* WebKit (iOS 26) reports `100dvh`, `100svh` and `100%` short by the status bar's height, so every
* screen ended ~59pt above the glass and the tab bar/composer sat on a dead band over the home
* indicator. Only `100lvh` reads the full height there. Nothing is lost by it: standalone has no
* browser toolbar to collapse, and iOS never resizes the layout for the keyboard (the visual
* viewport shrinks instead), so `lvh` and a correct `dvh` would be the same number. Android keeps
* `dvh`, which is what tracks its keyboard (`interactive-widget=resizes-content`). */
:root {
--app-h: 100dvh;
}
@media (display-mode: standalone) {
@supports (-webkit-touch-callout: none) {
:root {
--app-h: 100lvh;
}
}
}
@layer base {
* {
/* Full-strength ring, not /50: halved, the focus indicator measures 1.77:1 in light and 1.87:1
in dark — both under the 3:1 floor for a non-text indicator, on an app whose buttons type
into a live shell. */
@apply border-border outline-ring;
/* No faked bold, on any face. Aldrich ships one weight and the synthesized 500/600 smeared its
counters shut at body sizes; see the @font-face note above. Space Grotesk has a real 400-700
axis and is untouched by this. */
font-synthesis-weight: none;
}
html {
/* Respect notches; keep the app glued to the viewport edges on mobile. */
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
@apply bg-background text-foreground;
/* Spelled here rather than left to Tailwind's `--default-font-family`, so the app's face is a
fact you can read in this file instead of a preflight default you have to know about. */
font-family: var(--font-sans);
overscroll-behavior-y: none;
}
}
/* React Router's view transitions stay off (removed). But React Router can still fire document.startViewTransition
on a revalidation for a device carrying a stale persisted transition map from an older build (see
router.tsx). Keep the whole document unnamed so any such stray transition captures nothing and swaps
instantly — no full-page crossfade flicker. This is a no-op-izing safety net, not a transition.
The transitions the app starts itself, the glides below (lib/glide.ts), name only their own parts
under `html.glide` and leave this rule alone. */
:root {
view-transition-name: none;
}
/* ── Collie gallop loader ────────────────────────────────────────────────────────────────────────
The mascot as the app's activity indicator (components/dog-gallop.tsx). A horizontal sprite strip
— public/dog-gallop.png, 768×128 = six 128px cells — stepped through with steps(6): one frame per
step, GPU-cheap, no JS. `--dog-size` sets the square render size AND scales the sprite so a frame
is exactly one box wide; the strip is 6 boxes wide, so the box shows one frame. The keyframe walks
background-position left by the full strip width; steps(6) jump-end holds each frame's start, so it
cycles frames 0→5 and never lands on the off-screen 6th position. Idle rests on frame 0. */
.dog-gallop {
--dog-size: 1.5rem;
--dog-frames: 6;
display: inline-block;
flex: none;
width: var(--dog-size);
height: var(--dog-size);
background-image: url("/dog-gallop.png");
background-repeat: no-repeat;
background-position: 0 0;
background-size: calc(var(--dog-frames) * var(--dog-size)) var(--dog-size);
}
.dog-gallop--running {
animation: dog-gallop 0.6s steps(6) infinite;
}
@keyframes dog-gallop {
to {
background-position: calc(-1 * var(--dog-frames) * var(--dog-size)) 0;
}
}
/* Respect reduced motion: hold the rest frame instead of galloping. */
@media (prefers-reduced-motion: reduce) {
.dog-gallop--running {
animation: none;
}
}
/* Submission progress bar — a slim indeterminate strip across the very top of the viewport while a
mutation is in flight (components/busy-bar). The `appear` animation holds it fully invisible for
120ms (delay + `both` fill), so an action that resolves and unmounts within that window never
paints — no flash on a fast reply; `slide` is the indeterminate travel once it does show. */
.busy-bar {
position: fixed;
inset: 0 0 auto 0;
z-index: 60;
height: 2px;
overflow: hidden;
background: color-mix(in oklab, var(--primary) 22%, transparent);
animation: busy-bar-appear 140ms ease-out 120ms both;
}
.busy-bar__indicator {
display: block;
height: 100%;
width: 40%;
border-radius: 9999px;
background: var(--primary);
animation: busy-bar-slide 1.1s ease-in-out infinite;
}
@keyframes busy-bar-appear {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes busy-bar-slide {
from {
transform: translateX(-110%);
}
to {
transform: translateX(360%);
}
}
@keyframes busy-bar-pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
/* No horizontal travel under reduced motion — a gentle opacity pulse across the full width instead. */
@media (prefers-reduced-motion: reduce) {
.busy-bar__indicator {
width: 100%;
animation: busy-bar-pulse 1.1s ease-in-out infinite;
}
}
/* The working status dot's "live" pulse (components/status-badge.tsx StatusDot, `live` prop) — a
slow breathe, not a ping. One dot, opacity only, no second ring element and no scale: a phone
screen can carry several of these at once (pane chip, pane header) and an unsynchronized scaling
ring on each read as blinking. 2.4s is slow enough that a handful of un-phased dots still read as
"alive", not as a strobe. */
.status-breathe {
animation: status-breathe 2.4s ease-in-out infinite;
}
@keyframes status-breathe {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
.status-breathe {
animation: none;
}
}
/* THE HOLD, SHOWN (hooks/use-long-press.ts, M38/02). Every element that spreads `useLongPress`'s props
carries `data-holding` from 150ms after the finger lands until the hold fires or is cancelled: the
dashboard rows, the pane pills, the workspace and tab chips. While it does, the element eases toward
"pressed" over the time left to the hold's 450ms mark (the hook sets `animation-duration` inline to
exactly that), so the press visibly fills toward the moment the sheet opens. Linear, because the fill
is a clock the thumb reads, not a flourish.
- A TRANSFORM AND A TINT, NOTHING ELSE. `transform` composes with the element's own `active:scale-*`
press (that one is the `scale` property), so a pill already at 95% goes on to press in rather than
springing back to a keyframe value. The tint is an inset box-shadow in the element's own ink,
which paints over its background whatever that is (a muted chip, a primary chip, a bare row) and
follows its radius, and never touches the box: no size, no border, no padding (DESIGN.md §2).
Its strength is the registered `--hold-tint`, animated as a number: a keyframed box-shadow whose
colour is a `color-mix` over `currentColor` flips at the halfway point instead of fading
(measured in Chromium), while a registered percentage interpolates in every engine.
- IT ENDS AT ONCE. The hook drops the attribute on the fire and on every cancel, and a removed
animation has no exit to play.
- REDUCED MOTION keeps the tint and drops the scale. */
@property --hold-tint {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
[data-holding] {
box-shadow: inset 0 0 0 100vmax color-mix(in oklab, currentColor var(--hold-tint), transparent);
animation: hold-fill 300ms linear both;
}
@keyframes hold-fill {
from {
transform: none;
--hold-tint: 0%;