@lovett/ui 0.0.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
package/src/styles.css CHANGED
@@ -676,3 +676,1573 @@
676
676
  transition: none !important;
677
677
  }
678
678
  }
679
+
680
+ /* ---- BOARD TINT LADDER + BOARD CHROME ----
681
+ *
682
+ * ADR-146 D7 / `docs/design-briefs/tasks-board-design-language.md` §6.
683
+ *
684
+ * An earlier draft budgeted ~90 hand-written declarations (five levels x nine
685
+ * hues x two themes). This is the cheaper answer the prototype validated: mix
686
+ * the hue INTO the surface token with `color-mix(in oklab, ...)` rather than
687
+ * layering a translucent tint OVER it.
688
+ *
689
+ * Every rung is therefore OPAQUE in both themes by construction, which is the
690
+ * exact property CLAUDE.md §2 bans `--surface-overlay*` for lacking. One rule
691
+ * per hue; the ladder itself is authored once.
692
+ *
693
+ * `red` is deliberately absent: `--folder-red` in light mode is `207 14 15`,
694
+ * byte-identical to `--accent`. A red lane would put the accent on every card
695
+ * in it and collapse the one-accent-moment-per-view discipline.
696
+ *
697
+ * Usage: an element carries BOTH classes — `class="hue hue-blue"`. The
698
+ * `hueClass()` helper in `hue.ts` returns exactly that pair.
699
+ */
700
+ .hue {
701
+ --h: rgb(var(--folder-slate));
702
+ --tint-container: color-mix(in oklab, var(--h) 10%, rgb(var(--surface-card)));
703
+ --tint-group: color-mix(in oklab, var(--h) 18%, rgb(var(--surface-card)));
704
+ --tint-task: color-mix(in oklab, var(--h) 46%, rgb(var(--surface-card)));
705
+ --tint-fill: color-mix(in oklab, var(--h) 72%, rgb(var(--surface-card)));
706
+ --tint-done: color-mix(in oklab, var(--h) 22%, rgb(var(--surface-card)));
707
+ --tint-edge: color-mix(in oklab, var(--h) 34%, rgb(var(--surface-card)));
708
+ /* The INK rung — the only rung meant to be read as TEXT.
709
+ *
710
+ * The six rungs above mix the hue toward the SURFACE, which is right for a
711
+ * fill and wrong for a glyph: `color: var(--h)` on `--tint-group` measured
712
+ * 2.77:1 (slate), 3.13:1 (blue) and 3.68:1 (violet) in LIGHT mode, because
713
+ * the ladder was tuned against the dark theme where a mid-chroma hue on a
714
+ * near-black card passes for free.
715
+ *
716
+ * Mixing toward `--foreground` instead is theme-correct by construction: it
717
+ * darkens the hue in light mode and lightens it in dark, tracking the same
718
+ * token the body text uses. The hue survives — this is not "just use
719
+ * --foreground" — but it lands on the readable side of the surface in both
720
+ * themes. Measured per hue in a browser at both themes before this shipped.
721
+ *
722
+ * Use it for hue-coloured TEXT on a tint (avatar initials). Never for a
723
+ * fill: that is what the six rungs above are. */
724
+ --tint-ink: color-mix(in oklab, var(--h) 62%, rgb(var(--foreground)));
725
+ }
726
+ .hue-amber {
727
+ --h: rgb(var(--folder-amber));
728
+ }
729
+ .hue-emerald {
730
+ --h: rgb(var(--folder-emerald));
731
+ }
732
+ .hue-teal {
733
+ --h: rgb(var(--folder-teal));
734
+ }
735
+ .hue-blue {
736
+ --h: rgb(var(--folder-blue));
737
+ }
738
+ .hue-violet {
739
+ --h: rgb(var(--folder-violet));
740
+ }
741
+ .hue-pink {
742
+ --h: rgb(var(--folder-pink));
743
+ }
744
+ .hue-slate {
745
+ --h: rgb(var(--folder-slate));
746
+ }
747
+ .hue-ink {
748
+ --h: rgb(var(--folder-ink));
749
+ }
750
+
751
+ /* A ledger cell settles its colour when checked (~--dur-base), it never pops.
752
+ * Motion is punctuation: this and the drag transform are the board's only two
753
+ * moments. */
754
+ .ds-ledger-cell {
755
+ transition: background var(--dur-base) var(--ease-out);
756
+ }
757
+
758
+ /* Card hover lifts 1px with a shadow step (design brief §7). Authored here
759
+ * rather than as a JS hover state so it costs no render. */
760
+ .ds-board-card {
761
+ /* The RESTING shadow lives here, not inline on the element: an inline
762
+ box-shadow outranks a stylesheet rule, so while it was inline the hover
763
+ and focus steps below were dead CSS and the 1px lift was the entire hover
764
+ treatment. */
765
+ box-shadow: var(--shadow-sm);
766
+ transition:
767
+ box-shadow var(--dur-fast) var(--ease-out),
768
+ transform var(--dur-fast) var(--ease-out),
769
+ border-color var(--dur-fast) var(--ease-out);
770
+ }
771
+ /* Inside a DragOverlay the card is off the surface and carries the weight. */
772
+ .ds-board-card[data-overlay='true'] {
773
+ box-shadow: var(--shadow-lg);
774
+ }
775
+ /* `data-interactive` means the card has an `onOpen`. It is the ONLY thing that
776
+ gives the card a pointer cursor — the card is not a drag handle, so it must
777
+ never read `grab`, and a static card must claim no affordance at all. */
778
+ .ds-board-card[data-interactive='true'] {
779
+ cursor: pointer;
780
+ }
781
+ .ds-board-card[data-interactive='true']:hover {
782
+ box-shadow: var(--shadow-md);
783
+ transform: translateY(-1px);
784
+ }
785
+ /* Keyboard parity with hover. The card's keyboard control is the title button,
786
+ which carries its own ring; this lifts the CARD so the keyboard and pointer
787
+ paths point at visibly the same target. Scoped with :has so focusing the
788
+ grip or the overflow menu — which act on themselves, not on the card — does
789
+ not claim the card is about to open. */
790
+ .ds-board-card[data-interactive='true']:has(.ds-board-card-title button:focus-visible) {
791
+ box-shadow: var(--shadow-md);
792
+ }
793
+
794
+ @media (prefers-reduced-motion: reduce) {
795
+ .ds-ledger-cell,
796
+ .ds-board-card {
797
+ transition: none !important;
798
+ }
799
+ .ds-board-card[data-interactive='true']:hover {
800
+ transform: none !important;
801
+ }
802
+ }
803
+
804
+ /* ---- BOARD CHROME (<Board> / <BoardColumn>) ----
805
+ *
806
+ * `tasks-board-design-language.md` §4 + `tasks-board-interface-standards.md`.
807
+ *
808
+ * The concentric radius chain lives here and in `task-card.tsx`:
809
+ * column --radius-2xl (24) − --space-2 (8) = card --radius-lg (16)
810
+ * card 16 − --space-2 (8) media inset = media --radius-sm (8)
811
+ * Every step derives from the one above it and stays on the --radius-* scale.
812
+ */
813
+ .ds-board-scroller {
814
+ display: grid;
815
+ grid-auto-flow: column;
816
+ /* No fixed width — a column has a floor, then shares the row. When the
817
+ columns stop fitting, the scroller overflows and the next column peeks
818
+ past the fold rather than disappearing (interface-standards §3). */
819
+ grid-auto-columns: minmax(288px, 1fr);
820
+ /* ONE row, and it is exactly the scroller's own height — never its content's.
821
+ `grid-auto-rows` defaults to `auto`, which sizes the row to the TALLEST
822
+ lane, so a lane with forty cards grew the row, then the scroller, then the
823
+ board, and the lane body's `flex: 1 1 auto` never had a smaller definite
824
+ height to shrink against — it just got taller. `minmax(0, 1fr)` is the
825
+ fix and the `0` is load-bearing: a bare `1fr` still floors at min-content,
826
+ which is the whole stack of cards. With the row bounded, the lane body is
827
+ the box that runs out of room, so IT scrolls — which is the one scrollable
828
+ region the board is allowed (action-bar brief §7.4). */
829
+ grid-template-rows: minmax(0, 1fr);
830
+ align-items: stretch;
831
+ gap: var(--space-4);
832
+ /* The strip scrolls HORIZONTALLY and never vertically (action-bar brief §7.2).
833
+ `overflow-y` is declared explicitly because a single `overflow-x: auto`
834
+ would leave the block axis computing to `auto` too — the coercion rule for
835
+ a `visible` companion — which is a board-level vertical scrollbar waiting
836
+ to happen the moment anything inside is a pixel too tall. */
837
+ overflow-x: auto;
838
+ overflow-y: hidden;
839
+ /* No `padding-block-end`. The lanes stretch to this box's content edge, and
840
+ that edge has to BE the footer's top line (brief §7.3) — 12px of padding
841
+ under the lanes puts a gap there instead. A classic horizontal scrollbar
842
+ already takes its own space out of the content box; an overlay one floats
843
+ over the lane's own 8px of bottom padding, which holds no content. */
844
+ scroll-padding-inline: var(--space-4);
845
+ /* Fill the height the host gives us. A board that is the whole page wants
846
+ its lanes to reach the bottom and scroll inside themselves; a board nested
847
+ in a taller page just sizes to its content, because min-block-size:0 only
848
+ bites inside a flex/grid parent that has a height to share. */
849
+ min-block-size: 0;
850
+ block-size: 100%;
851
+ }
852
+
853
+ .ds-board-column {
854
+ /* The column adapts on its OWN width, not the viewport's — a board in a
855
+ narrow pane and a board on a wide screen are the same component. */
856
+ container-type: inline-size;
857
+ display: flex;
858
+ flex-direction: column;
859
+ gap: var(--space-2);
860
+ min-inline-size: 0;
861
+ background: rgb(var(--surface-frame));
862
+ border: 1px solid rgb(var(--border));
863
+ border-radius: var(--radius-2xl);
864
+ padding: var(--space-2);
865
+ transition-property: background-color, border-color;
866
+ transition-duration: var(--dur-fast);
867
+ transition-timing-function: var(--ease-out);
868
+ }
869
+
870
+ /* A COLLAPSED lane hugs its header while its neighbours keep filling.
871
+ *
872
+ * The scroller keeps `align-items: stretch` so an expanded lane still reaches
873
+ * the bottom of the page; `align-self` overrides that per item, which is the
874
+ * only way one lane can shrink on its own. Without it every lane inherits the
875
+ * tallest row's height and collapse appears to work only when EVERY lane is
876
+ * collapsed — which is exactly how it was reported.
877
+ *
878
+ * The body is not rendered at all when collapsed (see BoardColumn), so the
879
+ * collapsed shell is padding + header + border and nothing else. This state is
880
+ * NOT the filter-hidden one: a collapsed lane keeps its place in the strip and
881
+ * its own count; a filter-hidden lane leaves the strip for the rail. */
882
+ .ds-board-column[data-collapsed='true'] {
883
+ align-self: start;
884
+ }
885
+
886
+ /* Drag-over: the border STEPS, the shell lifts one notch. No accent tint —
887
+ the accent marks the single most important thing in a view, and "a card is
888
+ hovering here" is not it. */
889
+ .ds-board-column[data-over='true'] {
890
+ border-color: rgb(var(--border-strong));
891
+ background: rgb(var(--surface-hover));
892
+ }
893
+
894
+ .ds-board-column-head {
895
+ display: flex;
896
+ align-items: center;
897
+ gap: var(--space-2);
898
+ /* --space-1, not --space-2: the collapse caret is a borderless icon control
899
+ and the larger inset was pushing it off the column's own alignment
900
+ column, so it read as crowded against the label rather than leading it.
901
+ Owner correction, 2026-09-06. */
902
+ padding-inline-start: var(--space-1);
903
+ min-block-size: var(--ctrl-sm);
904
+ }
905
+
906
+ .ds-board-column-dot {
907
+ flex: none;
908
+ inline-size: 8px;
909
+ block-size: 8px;
910
+ border-radius: 2px;
911
+ background: var(--h);
912
+ }
913
+
914
+ .ds-board-column-label {
915
+ margin: 0;
916
+ min-inline-size: 0;
917
+ font-size: 12.5px;
918
+ /* 600, not 650. Inter ships here as discrete STATIC faces (400/500/600/700 —
919
+ see apps/workspace/index.html), so 650 rounds up to 700 and the label
920
+ renders a full step heavier than authored. A variable face would honour
921
+ it; this one cannot. */
922
+ font-weight: 600;
923
+ letter-spacing: -0.005em;
924
+ color: rgb(var(--foreground));
925
+ /* `break-word`, not `anywhere`. `anywhere` is allowed to break INSIDE a word
926
+ at any character AND it feeds min-content sizing, which measured as
927
+ "countersignatur/e" splitting mid-syllable while the header still grew the
928
+ column to 75px — so it broke the word AND failed to save the space. */
929
+ overflow-wrap: break-word;
930
+ }
931
+
932
+ .ds-board-column-count {
933
+ margin-inline-start: auto;
934
+ min-inline-size: 2ch;
935
+ text-align: end;
936
+ font-size: 11.5px;
937
+ font-weight: 600;
938
+ /* --text-muted is documented (tokens.css) as the NON-TEXT / disabled rung:
939
+ 3.24:1 measured on a light column. A count is real text, so it goes one
940
+ tier up to --text-tertiary. */
941
+ color: rgb(var(--text-tertiary));
942
+ }
943
+
944
+ /* 12px between adjacent bordered controls (interface-standards §3). */
945
+ .ds-board-column-actions {
946
+ display: flex;
947
+ align-items: center;
948
+ /* --space-2. At --space-3 the count, the + and the overflow read as three
949
+ unrelated controls rather than one action group. Owner correction,
950
+ 2026-09-06. */
951
+ gap: var(--space-2);
952
+ margin-inline-start: var(--space-3);
953
+ }
954
+
955
+ .ds-board-column-body {
956
+ display: flex;
957
+ flex-direction: column;
958
+ gap: var(--space-2);
959
+ min-block-size: 96px;
960
+ /* Overridable by the consuming surface. Capping it is what produces the
961
+ next-card peek instead of an infinitely tall column. Declared here rather
962
+ than spelled as a `var(--x, fallback)` so the token-shape guard can still
963
+ catch a typo in the name. */
964
+ /* Was a fixed 64vh. The lane now takes whatever height the column has left
965
+ after its header, so a full-page board reaches the bottom and a short one
966
+ does not stretch. The knob stays for hosts that want to cap it. */
967
+ --board-column-max-block-size: none;
968
+ max-block-size: var(--board-column-max-block-size);
969
+ flex: 1 1 auto;
970
+ overflow-y: auto;
971
+ scroll-padding-block-end: var(--space-6);
972
+ }
973
+
974
+ .ds-board-column-empty {
975
+ margin: 0;
976
+ padding-block: var(--space-4);
977
+ padding-inline: var(--space-3);
978
+ font-size: 12px;
979
+ line-height: 1.45;
980
+ text-align: center;
981
+ color: rgb(var(--text-tertiary));
982
+ }
983
+
984
+ .ds-board-columns-end {
985
+ align-self: start;
986
+ padding-block-start: var(--space-1);
987
+ }
988
+
989
+ /* ---- HIDDEN COLUMNS (the filtered case) ----
990
+ *
991
+ * `tasks-board-header-primitives.md` §3. A lane emptied BY A FILTER leaves the
992
+ * strip and stacks here as a row carrying `0 / n` — the count of what the
993
+ * filter is hiding in that lane. Nothing rotates: a row stays readable and has
994
+ * somewhere to put the count, which a turned-on-its-side column does not.
995
+ *
996
+ * The rail is a lane-level container, so it takes the column's own frame and
997
+ * the same concentric chain: --radius-2xl (24) − --space-2 (8) = the rows'
998
+ * --radius-lg (16).
999
+ */
1000
+ .ds-board-hidden-rail {
1001
+ align-self: start;
1002
+ display: flex;
1003
+ flex-direction: column;
1004
+ gap: var(--space-2);
1005
+ min-inline-size: 0;
1006
+ background: rgb(var(--surface-frame));
1007
+ border: 1px solid rgb(var(--border));
1008
+ border-radius: var(--radius-2xl);
1009
+ padding: var(--space-2);
1010
+ }
1011
+
1012
+ .ds-board-hidden-rail-head {
1013
+ display: flex;
1014
+ align-items: center;
1015
+ gap: var(--space-2);
1016
+ min-block-size: var(--ctrl-sm);
1017
+ padding-inline: var(--space-2);
1018
+ background: transparent;
1019
+ border: none;
1020
+ border-radius: var(--radius-lg);
1021
+ color: rgb(var(--foreground));
1022
+ cursor: pointer;
1023
+ text-align: start;
1024
+ }
1025
+
1026
+ .ds-board-hidden-rail-head:focus-visible {
1027
+ outline: none;
1028
+ box-shadow: var(--ring-focus);
1029
+ }
1030
+
1031
+ .ds-board-hidden-rail-title {
1032
+ font-size: 12.5px;
1033
+ font-weight: 600;
1034
+ letter-spacing: -0.005em;
1035
+ }
1036
+
1037
+ .ds-board-hidden-rail-count {
1038
+ margin-inline-start: auto;
1039
+ min-inline-size: 2ch;
1040
+ text-align: end;
1041
+ font-size: 11.5px;
1042
+ font-weight: 600;
1043
+ color: rgb(var(--text-tertiary));
1044
+ }
1045
+
1046
+ .ds-board-hidden-rail-list {
1047
+ display: flex;
1048
+ flex-direction: column;
1049
+ gap: var(--space-1);
1050
+ margin: 0;
1051
+ padding: 0;
1052
+ list-style: none;
1053
+ }
1054
+
1055
+ /* A stub of a lane, not a card of its own: opaque surface, hairline border,
1056
+ * and the lane's marker on the left exactly where the column header puts it. */
1057
+ .ds-board-hidden-row {
1058
+ display: flex;
1059
+ align-items: center;
1060
+ gap: var(--space-2);
1061
+ min-block-size: var(--ctrl-sm);
1062
+ padding-inline: var(--space-2);
1063
+ background: rgb(var(--surface-card));
1064
+ border: 1px solid rgb(var(--border));
1065
+ border-radius: var(--radius-lg);
1066
+ }
1067
+
1068
+ .ds-board-hidden-row-label {
1069
+ min-inline-size: 0;
1070
+ font-size: 12.5px;
1071
+ font-weight: 500;
1072
+ color: rgb(var(--text-secondary));
1073
+ overflow-wrap: break-word;
1074
+ }
1075
+
1076
+ .ds-board-hidden-row-count {
1077
+ margin-inline-start: auto;
1078
+ /* A min-width slot so `0 / 9` and `0 / 12` do not resize the row. */
1079
+ min-inline-size: 5ch;
1080
+ text-align: end;
1081
+ font-size: 11.5px;
1082
+ font-weight: 600;
1083
+ color: rgb(var(--text-tertiary));
1084
+ }
1085
+
1086
+ /* The view-level footer. `display: contents` until there is something to say,
1087
+ * so a board with no filtering keeps the box tree it had before. */
1088
+ .ds-board-shell {
1089
+ display: contents;
1090
+ }
1091
+
1092
+ .ds-board-shell[data-footer='true'] {
1093
+ display: flex;
1094
+ flex-direction: column;
1095
+ /* No gap: the footer is separated from the board by its own BORDER, not by
1096
+ space and not by a shadow (action-bar brief §6.2). */
1097
+ flex: 1 1 auto;
1098
+ min-block-size: 0;
1099
+ }
1100
+
1101
+ .ds-board-shell[data-footer='true'] > .ds-board-scroller {
1102
+ block-size: auto;
1103
+ flex: 1 1 auto;
1104
+ min-block-size: 0;
1105
+ }
1106
+
1107
+ /* The footer — a full-width strip pinned under the board area, with its
1108
+ * content centred (brief §7, the diagram). It takes its height out of the
1109
+ * board's, so the lanes shorten to meet it and the page still does not scroll;
1110
+ * when no filter is hiding anything it is not rendered at all and the lanes
1111
+ * reclaim the height. `flex: none` so it is never the box that shrinks. */
1112
+ .ds-board-hidden-summary {
1113
+ display: flex;
1114
+ align-items: center;
1115
+ justify-content: center;
1116
+ gap: var(--space-3);
1117
+ flex-wrap: wrap;
1118
+ flex: none;
1119
+ /* A gap ABOVE the rule, so the lanes do not sit on the divider. The margin
1120
+ lands outside the border, which is what puts the space between the lane's
1121
+ bottom edge and the line rather than inside the footer band. */
1122
+ margin-block-start: var(--space-3);
1123
+ /* Symmetric, and one step under the inline padding: the line box already
1124
+ carries space the glyphs do not fill, so equal numbers read bottom-heavy
1125
+ (interface-standards — optically align spacing around text). */
1126
+ padding-block: var(--space-3);
1127
+ padding-inline: var(--space-4);
1128
+ border-block-start: 1px solid rgb(var(--border));
1129
+ }
1130
+
1131
+ .ds-board-hidden-summary-text {
1132
+ font-size: 12px;
1133
+ color: rgb(var(--text-tertiary));
1134
+ }
1135
+
1136
+ /* The ring settles into its new arc (--dur-base, authored in CompletionRing);
1137
+ * reduced motion jumps to the end state instead. */
1138
+ @media (prefers-reduced-motion: reduce) {
1139
+ .ds-status-ring circle {
1140
+ transition: none !important;
1141
+ }
1142
+ }
1143
+
1144
+ /* The card responds to the COLUMN's width, not the device's.
1145
+ *
1146
+ * The breakpoint is ABOVE the track floor on purpose. `grid-auto-columns:
1147
+ * minmax(288px, 1fr)` means a column is never narrower than 288px, so the
1148
+ * former `max-width: 250px` could not fire at any viewport — measured 288px at
1149
+ * every width from 1440 down to 380. 320px sits just above the floor, so the
1150
+ * compact treatment engages as soon as the columns stop having slack and are
1151
+ * sitting on their minimum. */
1152
+ @container (max-width: 320px) {
1153
+ .ds-board-card-title {
1154
+ font-size: 14px;
1155
+ }
1156
+ .ds-board-card-footer {
1157
+ row-gap: var(--space-2);
1158
+ }
1159
+ }
1160
+
1161
+ /* ---- HUE SWATCH (the column colour radiogroup) ----
1162
+ *
1163
+ * Two rules from CLAUDE.md §2 collide on this control and the CSS is where
1164
+ * they are resolved:
1165
+ *
1166
+ * 1. A SELECTED state may never be conveyed by a translucent tint. It steps
1167
+ * up to an opaque surface sandwiched between an accent ring and the
1168
+ * surface behind it — here, a 2px `--surface-card` gap then a 2px
1169
+ * `--accent` ring, which reads as a halo at any hue including slate.
1170
+ * 2. The swatch's JOB is to show the hue, so nothing is drawn on top of it:
1171
+ * the fill is `--tint-fill` (the ladder's most saturated rung, so teal
1172
+ * and emerald are actually distinguishable) with no glyph. "Not colour
1173
+ * alone" is carried by the accessible name (the hue's name) and
1174
+ * `aria-checked`, not by a tick that would need its own contrast budget.
1175
+ *
1176
+ * Authored here rather than inline because the SELECTED and the FOCUSED ring
1177
+ * are both box-shadows and must be able to coexist — an inline box-shadow
1178
+ * cannot be extended by a `:focus-visible` class. */
1179
+ .ds-hue-swatch:focus-visible {
1180
+ outline: none;
1181
+ box-shadow: var(--ring-focus);
1182
+ }
1183
+ .ds-hue-swatch[aria-checked='true'] {
1184
+ box-shadow:
1185
+ 0 0 0 2px rgb(var(--surface-card)),
1186
+ 0 0 0 4px rgb(var(--accent));
1187
+ }
1188
+ /* Focused AND selected: a second, wider ring, so keyboard focus is still
1189
+ locatable on the swatch that already carries the selection halo. */
1190
+ .ds-hue-swatch[aria-checked='true']:focus-visible {
1191
+ box-shadow:
1192
+ 0 0 0 2px rgb(var(--surface-card)),
1193
+ 0 0 0 4px rgb(var(--accent)),
1194
+ 0 0 0 6px rgb(var(--surface-card)),
1195
+ 0 0 0 8px rgb(var(--accent) / 0.45);
1196
+ }
1197
+
1198
+ .ds-board-card-grip:hover {
1199
+ color: rgb(var(--foreground));
1200
+ background: rgb(var(--surface-inset));
1201
+ }
1202
+
1203
+ /* ---- AVATAR ----------------------------------------------------------------
1204
+ * A person: a photo when there is one, initials on an identity tint when there
1205
+ * is not. `avatar.tsx` renders through these classes — ADR-147 D14.
1206
+ *
1207
+ * Consolidates four forks (ADR-147 Context). Read `avatar.tsx`'s header for
1208
+ * what each of them got wrong; this file owns the two decisions that are
1209
+ * colour rather than API:
1210
+ *
1211
+ * 1. THE FILL IS OPAQUE BY CONSTRUCTION. The community and admin forks filled
1212
+ * with `rgb(var(--info) / 0.14)`. CLAUDE.md §2's hard rule is that a
1213
+ * surface is never a translucent tint: two alpha layers composite against
1214
+ * whatever happens to be behind them, and the result is not predictable
1215
+ * from the tokens. Every rung below mixes the hue INTO an opaque surface.
1216
+ * 2. THE GLYPH TAKES THE INK RUNG, NOT THE RAW HUE. See `--avatar-ink`.
1217
+ *
1218
+ * The three rungs use the same ratios, in the same order, against the same
1219
+ * base as the tasks board's `.hue` ladder (`feat/tasks-lens`), so that when
1220
+ * that branch merges its `AvatarStack` adopts this primitive without a single
1221
+ * avatar changing colour. Reconcile the two into one ladder at that merge.
1222
+ */
1223
+ .ds-avatar,
1224
+ .ds-avatar-group__more {
1225
+ /* Diameter and type size. Overridden by the size modifiers below, or by the
1226
+ * `--avatar-size` / `--avatar-font` pair for a caller-supplied pixel size.
1227
+ * These are off the `--ctrl-*` scale on purpose: an avatar is not a control,
1228
+ * and 22 / 32 / 52 are the diameters the four forks already ship at. */
1229
+ --avatar-size: 22px;
1230
+ --avatar-font: 9.5px;
1231
+
1232
+ position: relative;
1233
+ display: inline-grid;
1234
+ place-items: center;
1235
+ flex: none;
1236
+ /* Explicit rather than inherited from a host's preflight: the 1px edge must
1237
+ * come out of the diameter, or a 22px avatar renders 24px wide. */
1238
+ box-sizing: border-box;
1239
+ inline-size: var(--avatar-size);
1240
+ block-size: var(--avatar-size);
1241
+ border-radius: var(--radius-full);
1242
+ overflow: hidden;
1243
+ font-family: var(--font-sans);
1244
+ font-size: var(--avatar-font);
1245
+ font-weight: 700;
1246
+ line-height: 1;
1247
+ letter-spacing: -0.02em;
1248
+ user-select: none;
1249
+ }
1250
+
1251
+ .ds-avatar {
1252
+ --avatar-hue: rgb(var(--folder-slate));
1253
+
1254
+ /* FILL and EDGE mix the hue toward the card, which is right for a surface. */
1255
+ background: color-mix(in oklab, var(--avatar-hue) 18%, rgb(var(--surface-card)));
1256
+ border: 1px solid color-mix(in oklab, var(--avatar-hue) 34%, rgb(var(--surface-card)));
1257
+
1258
+ /* The INK rung — the only one meant to be read as TEXT.
1259
+ *
1260
+ * `color: var(--avatar-hue)` on that fill measured 2.77:1 (slate), 3.13:1
1261
+ * (blue) and 3.68:1 (violet) in LIGHT mode at 9.5px/700, because the ladder
1262
+ * was tuned against the dark theme, where a mid-chroma hue on a near-black
1263
+ * card passes for free. Mixing toward `--foreground` instead darkens in
1264
+ * light and lightens in dark rather than being tuned for one theme; the hue
1265
+ * survives, so this is not "just use --foreground".
1266
+ *
1267
+ * Measured per hue in a browser in both themes before it shipped (tasks
1268
+ * board, `--tint-ink`). `diag:contrast` cannot see this pair — it resolves
1269
+ * token pairs, not `color-mix()` — so do not read a green run as having
1270
+ * checked it. */
1271
+ color: color-mix(in oklab, var(--avatar-hue) 62%, rgb(var(--foreground)));
1272
+ }
1273
+
1274
+ /* Size modifiers. Applied to a face or to the group's `+N` chip alike. */
1275
+ .ds-avatar--md {
1276
+ --avatar-size: 32px;
1277
+ --avatar-font: 12px;
1278
+ }
1279
+ .ds-avatar--lg {
1280
+ --avatar-size: 52px;
1281
+ --avatar-font: 19px;
1282
+ }
1283
+
1284
+ /* Identity hues. `red` is absent on purpose — `--folder-red` is byte-identical
1285
+ * to `--accent` in light mode, so a red face would spend the view's single
1286
+ * accent moment on a person. */
1287
+ .ds-avatar--amber { --avatar-hue: rgb(var(--folder-amber)); }
1288
+ .ds-avatar--emerald { --avatar-hue: rgb(var(--folder-emerald)); }
1289
+ .ds-avatar--teal { --avatar-hue: rgb(var(--folder-teal)); }
1290
+ .ds-avatar--blue { --avatar-hue: rgb(var(--folder-blue)); }
1291
+ .ds-avatar--violet { --avatar-hue: rgb(var(--folder-violet)); }
1292
+ .ds-avatar--pink { --avatar-hue: rgb(var(--folder-pink)); }
1293
+ .ds-avatar--slate { --avatar-hue: rgb(var(--folder-slate)); }
1294
+ .ds-avatar--ink { --avatar-hue: rgb(var(--folder-ink)); }
1295
+
1296
+ /* The monogram. It has its own element because `text-box-trim` is NOT
1297
+ * inherited and a grid container has no line boxes of its own — declaring it
1298
+ * on `.ds-avatar` applies to nothing, which is why the first attempt at this
1299
+ * fix in @lovett/ds measured identically to no fix at all.
1300
+ *
1301
+ * `place-items: center` centres the LINE BOX, and a line box is not the
1302
+ * letter: a capital has no descender, so the empty descender space pushes the
1303
+ * visible ink up — measured at 1.3px high in a 24px avatar. `text-box` trims
1304
+ * to the cap-height/baseline edges so what gets centred is the LETTER, which
1305
+ * scales with every size instead of being nudged for one.
1306
+ *
1307
+ * Degrades correctly by construction: a browser without support ignores both
1308
+ * declarations and renders what shipped before — slightly high, not broken.
1309
+ * That is why there is no @supports fallback nudge; one would double-correct
1310
+ * the moment support lands. */
1311
+ .ds-avatar__mono {
1312
+ text-box-trim: trim-both;
1313
+ text-box-edge: cap alphabetic;
1314
+ }
1315
+
1316
+ /* The photo sits OVER the monogram, which stays in the DOM, so a broken `src`
1317
+ * degrades to a legible letter rather than a coloured hole. */
1318
+ .ds-avatar__img {
1319
+ position: absolute;
1320
+ inset: 0;
1321
+ inline-size: 100%;
1322
+ block-size: 100%;
1323
+ object-fit: cover;
1324
+ border-radius: inherit;
1325
+ }
1326
+
1327
+ /* The separating ring for overlapping faces, in the HOST SURFACE's colour —
1328
+ * which cannot be guessed from inside the component. A stack on a card sits on
1329
+ * `--surface-card`; one in an inset well sits on `--surface-inset`. Set it
1330
+ * with the `.ds-avatar-on-*` classes below rather than an inline style, so
1331
+ * lens code never has to name an internal surface token (CLAUDE.md §5). */
1332
+ .ds-avatar--ring {
1333
+ box-shadow: 0 0 0 2px var(--avatar-ring, rgb(var(--surface-card)));
1334
+ }
1335
+
1336
+ .ds-avatar-on-card { --avatar-ring: rgb(var(--surface-card)); }
1337
+ .ds-avatar-on-frame { --avatar-ring: rgb(var(--surface-frame)); }
1338
+ .ds-avatar-on-inset { --avatar-ring: rgb(var(--surface-inset)); }
1339
+
1340
+ /* ---- AVATAR GROUP ----
1341
+ * The overlap is a negative margin rather than absolute positioning, so the
1342
+ * group keeps an intrinsic width — a participant stack has to grow with the
1343
+ * number of people in its thread. */
1344
+ .ds-avatar-group {
1345
+ display: inline-flex;
1346
+ align-items: center;
1347
+ flex: none;
1348
+ }
1349
+ .ds-avatar-group--overlap > * + * {
1350
+ margin-inline-start: calc(var(--space-2) * -1);
1351
+ }
1352
+ .ds-avatar-group--overlap > * {
1353
+ box-shadow: 0 0 0 2px var(--avatar-ring, rgb(var(--surface-card)));
1354
+ }
1355
+ .ds-avatar-group--spaced {
1356
+ gap: var(--space-1);
1357
+ }
1358
+
1359
+ /* The overflow count. Reads as one of the faces, not as a badge on them, so it
1360
+ * shares the geometry block at the top of this section. Neutral rather than
1361
+ * hued: it is not a person. Tabular so +9 becoming +10 cannot shift the faces
1362
+ * to its left.
1363
+ *
1364
+ * `--text-secondary`, not the `--text-tertiary` the task-card fork used: at
1365
+ * these diameters the label is 9.5-12px, which is normal text, and tertiary on
1366
+ * `--surface-inset` measures 3.70:1 in dark (4.52:1 light) — below AA. Secondary
1367
+ * measures 5.36:1 dark / 6.91:1 light on the same fill. */
1368
+ .ds-avatar-group__more {
1369
+ background: rgb(var(--surface-inset));
1370
+ color: rgb(var(--text-secondary));
1371
+ font-weight: 600;
1372
+ font-variant-numeric: tabular-nums;
1373
+ }
1374
+
1375
+ /* ── THREAD ──────────────────────────────────────────────────────────────────
1376
+ `--thread-inner-radius` is a COMPONENT-LOCAL custom property, not a token.
1377
+ It exists because the concentric rule (docs/design-briefs/
1378
+ tasks-board-interface-standards.md §1) makes an inner radius a FUNCTION of
1379
+ its container rather than a value someone picks:
1380
+
1381
+ innerRadius = outerRadius - padding
1382
+
1383
+ The panel is `--radius-xl` (20) with `--space-3` (12) of padding, so
1384
+ everything nested directly inside it is 20 - 12 = 8 = `--radius-sm`. Every
1385
+ inner box (the composer shell, an attachment thumbnail, the failure notice)
1386
+ reads the property instead of naming a corner, so a host that mounts the
1387
+ thread inside a differently-rounded container re-derives the whole chain by
1388
+ setting ONE value rather than overriding five components:
1389
+
1390
+ .my-drawer .ds-thread { --thread-inner-radius: var(--radius-md); }
1391
+
1392
+ ...when that drawer is `--radius-2xl` (24) with `--space-3` (12) of padding.
1393
+
1394
+ This lives in `styles.css` rather than a `thread.css` because the package
1395
+ ships exactly two consumer stylesheets and has no CSS loader in its tsup
1396
+ build — a per-component import would make esbuild emit a third artifact that
1397
+ every consumer would then have to know about. Same call `.ds-avatar` made. */
1398
+ .ds-thread {
1399
+ /* THE THREAD OWNS ITS SCROLL. Its body (`.ds-thread__scroll`) is
1400
+ `overflow-y: auto` and the root composer sits OUTSIDE it, pinned — but
1401
+ that only does anything if the section itself is bounded. In a host that
1402
+ forgets to bound it (a page, a drawer body that is itself a scroller) the
1403
+ section grew to its content and the HOST scrolled the whole thread,
1404
+ composer and all. Owner, 2026-09-07: "the entire viewport is scrolling
1405
+ down, this is a common issue, make a permanent fix." This is the fix that
1406
+ does not depend on the host: the thread can never be taller than the
1407
+ viewport. A host that bounds it properly (`h-full min-h-0` in a flex
1408
+ column) passes `100%` and the flex chain governs instead. Same rule as the
1409
+ board: fill the available height and stop; only the inside scrolls. */
1410
+ --thread-max-block-size: 100dvh;
1411
+ max-block-size: var(--thread-max-block-size);
1412
+ /* 20 (--radius-xl) - 12 (--space-3) = 8 (--radius-sm). This is the radius of
1413
+ a ROOT THREAD CARD, which is the only thing nested directly inside the
1414
+ panel. */
1415
+ --thread-inner-radius: var(--radius-sm);
1416
+
1417
+ /* The chain continues one rung further INSIDE a root thread card, and it
1418
+ continues by the same mechanism rather than a second variable: the card
1419
+ element re-declares `--thread-inner-radius` to this value for its own
1420
+ descendants, so the composer shell, an attachment thumbnail and the
1421
+ failure notice all keep reading exactly one property and get the right
1422
+ corner for wherever they happen to be mounted. The root composer, which
1423
+ sits directly on the panel and not in a card, still resolves 8.
1424
+
1425
+ The card is 8 with `--space-2` (8) of block padding, so a STRICTLY
1426
+ concentric inner corner is 0. Zero is right for a square inner well and
1427
+ wrong for a control that has to read as a control, and §1 of the interface
1428
+ standards exempts controls from the chain for exactly that reason ("chips,
1429
+ date pill, controls sit flat on the card, not nested inside a rounded box").
1430
+ `--radius-xs` (6) is the next rung DOWN from the card, so the chain still
1431
+ never inverts. */
1432
+ --thread-card-inner-radius: var(--radius-xs);
1433
+
1434
+ /* ── RAIL GEOMETRY ────────────────────────────────────────────────────────
1435
+ Two authored values; everything else is derived from them. The rail is a
1436
+ CONNECTOR — it leaves the parent's avatar, ends in the collapse circle, and
1437
+ turns an elbow into the child's avatar — so every number below is a
1438
+ consequence of where those avatars are, never a hand-picked offset. Change
1439
+ `--thread-avatar` and the entire figure moves correctly.
1440
+
1441
+ AUTHORED: */
1442
+
1443
+ /* The avatar COLUMN width, which is the contract the rail is measured
1444
+ against. The byline renders `<Avatar size="md">` (32) into a cell exactly
1445
+ this wide, so if that named size ever moved, the rail would still be on the
1446
+ column's centre line — the avatar would simply no longer fill its cell.
1447
+ Keep the two in step; the column is what everything else trusts. */
1448
+ --thread-avatar: var(--space-8);
1449
+ /* Avatar → text. Also the offset the body and the action row hang from, so
1450
+ the faces form one continuous edge down the card. */
1451
+ --thread-gap: var(--space-2);
1452
+ /* ONE nesting step, identical at every level. Must exceed `--thread-avatar`/2
1453
+ or the elbow has no room to turn. At 24 against a 32 avatar the child face
1454
+ steps three quarters of a face inward, and the default clamp of 6 tops the
1455
+ total indent out at 144px — a ~380px task drawer still has a readable
1456
+ column at the deepest visible level. */
1457
+ --thread-indent: var(--space-6);
1458
+ /* Vertical separation between comments, and the block-start padding every
1459
+ comment carries. It is a comment's OWN padding rather than a container gap
1460
+ for a structural reason: sibling boxes must be flush so their connector
1461
+ segments concatenate into one unbroken line. It is also 4x the `--space-1`
1462
+ used inside a comment, so grouping is carried by space alone. */
1463
+ --thread-row-gap: var(--space-3);
1464
+ /* The collapse circle. Two thirds of a face — reads as a control, stays
1465
+ quiet. The TARGET is the full `--thread-avatar` column beneath the avatar,
1466
+ so the 24x24 floor is met by transparent padding, not by this. */
1467
+ --thread-circle: var(--space-4);
1468
+ /* The engagement row's floor, and every reaction chip's height. 24 — the
1469
+ accessibility floor. It was 32 for one pass; the owner's eyeball pass
1470
+ (2026-09-07) asked for ~30% smaller icons, so the row came back down and
1471
+ the glyphs inside shrank 16 → 12 (Lucide is a 24-grid set, so 12 is exact
1472
+ half-scale and strokes land on pixel boundaries; 11 would not). It cannot
1473
+ go below 24. The collapse circle derives its own vertical centring from
1474
+ this, so the two stay level. */
1475
+ --thread-action-h: var(--space-6);
1476
+
1477
+ /* Emoji need an explicit stack: a host that sets a narrow `font-family` on an
1478
+ ancestor turns a reaction into tofu, and this primitive does not control
1479
+ its host's typography. Overridable so a product that bundles its own emoji
1480
+ font can point at it. */
1481
+ --font-emoji: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
1482
+
1483
+ /* DERIVED — do not author these: */
1484
+
1485
+ /* The parent avatar's centre line. The rail's only x. */
1486
+ --thread-rail-x: calc(var(--thread-avatar) / 2);
1487
+ /* Horizontal distance from that line to the child's avatar edge, and — the
1488
+ same value — the elbow's corner radius, which is what makes the elbow a
1489
+ pure quarter arc that terminates exactly ON the avatar rather than near it.
1490
+ 24 - 16 = 8, i.e. `--radius-sm`, but derived so it cannot drift. */
1491
+ --thread-elbow-run: calc(var(--thread-indent) - var(--thread-rail-x));
1492
+ /* Distance from a comment's box top to its avatar's centre — where the elbow
1493
+ lands, and where a non-terminal sibling's trunk picks the line back up. */
1494
+ --thread-avatar-center: calc(var(--thread-row-gap) + var(--thread-avatar) / 2);
1495
+ /* Where the body, the action row and the reply composer hang, so all three
1496
+ share one alignment edge with the byline's text. */
1497
+ --thread-content-x: calc(var(--thread-avatar) + var(--thread-gap));
1498
+ }
1499
+
1500
+ /* ── RICH COMPOSER (ADR-148) ─────────────────────────────────────────────────
1501
+ The TipTap surface inside the composer shell, and the bubble menu that
1502
+ floats over it. Same concentric rule as composer.tsx: the shell reads
1503
+ `--thread-inner-radius`; anything nested one rung further in (the code
1504
+ block, a mention chip) takes the next rung down, `--radius-xs`.
1505
+
1506
+ The editor RESERVES the plain composer's height — `rows` lines of
1507
+ 13px / 1.55 plus the same block padding — so the D5 loading state (the plain
1508
+ composer, disabled) and the mounted editor occupy the same box and the swap
1509
+ moves nothing. `--composer-rows` is declared here and overridden inline by
1510
+ the composer from its `rows` prop; `--composer-line` is the line box the
1511
+ textarea already uses. */
1512
+ /* The composer's action row is a CONTAINER, so what drops out at a narrow
1513
+ width is decided by the composer's own size and not the viewport's - the
1514
+ same composer lives full-width on a page and ~320px wide in a drawer
1515
+ (interface standards §3: container queries, not device breakpoints).
1516
+ Below the threshold two things go, both meaningless on a touch device: the
1517
+ character counter, and the send button's keycap. Without them the fixed
1518
+ cluster no longer exceeds a 390px screen and the send button stops being cut
1519
+ in half. */
1520
+ .ds-composer-footer {
1521
+ container-type: inline-size;
1522
+ container-name: composer-footer;
1523
+ }
1524
+ @container composer-footer (max-width: 380px) {
1525
+ .ds-composer-meta,
1526
+ .ds-composer-keycap {
1527
+ display: none;
1528
+ }
1529
+ }
1530
+
1531
+ .ds-thread-rich-composer {
1532
+ --composer-rows: 3;
1533
+ --composer-max-rows: 12;
1534
+ --composer-line: 1.55em;
1535
+ }
1536
+ .ds-thread-rich-composer .ProseMirror {
1537
+ min-block-size: calc(var(--composer-rows) * var(--composer-line) + 2 * var(--space-2));
1538
+ /* And a CEILING. A contenteditable with only a floor grows with every line
1539
+ typed, pushing the pinned composer taller and the host scroller with it —
1540
+ the second half of the "entire viewport scrolls" report. Past
1541
+ `--composer-max-rows` (12) the editor scrolls internally; ProseMirror's
1542
+ own scrollIntoView then keeps the caret visible inside the editor rather
1543
+ than dragging the page. The plain textarea never had this problem: `rows`
1544
+ is fixed and `resize: none`. */
1545
+ max-block-size: calc(var(--composer-max-rows) * var(--composer-line) + 2 * var(--space-2));
1546
+ overflow-y: auto;
1547
+ /* Stop WHEEL and TOUCH from chaining out of the editor once it is at its
1548
+ own limit. `handleScrollToSelection` handles the caret; this handles the
1549
+ pointer. Without both, the page still moves — they are different input
1550
+ paths to the same defect. */
1551
+ overscroll-behavior: contain;
1552
+ padding: var(--space-2) var(--space-3);
1553
+ font-family: var(--font-sans);
1554
+ font-size: 13px;
1555
+ line-height: 1.55;
1556
+ color: rgb(var(--foreground));
1557
+ outline: none;
1558
+ white-space: pre-wrap;
1559
+ overflow-wrap: anywhere;
1560
+ caret-color: rgb(var(--accent));
1561
+ }
1562
+ .ds-thread-rich-composer .ProseMirror[contenteditable="false"] {
1563
+ color: rgb(var(--text-tertiary));
1564
+ cursor: default;
1565
+ }
1566
+ /* The Placeholder extension marks the empty first block; the hint is drawn
1567
+ here so it sits exactly where the first character will land. */
1568
+ .ds-thread-rich-composer .ProseMirror p.is-editor-empty:first-child::before {
1569
+ content: attr(data-placeholder);
1570
+ color: rgb(var(--text-tertiary));
1571
+ float: left;
1572
+ block-size: 0;
1573
+ pointer-events: none;
1574
+ }
1575
+ /* Block rhythm. One gap between blocks, nothing before the first or after the
1576
+ last, so the editor and the rendered comment share a silhouette. */
1577
+ .ds-thread-rich-composer .ProseMirror > * + * {
1578
+ margin-block-start: var(--space-2);
1579
+ }
1580
+ .ds-thread-rich-composer .ProseMirror h1,
1581
+ .ds-thread-rich-composer .ProseMirror h2,
1582
+ .ds-thread-rich-composer .ProseMirror h3 {
1583
+ line-height: 1.3;
1584
+ color: rgb(var(--foreground));
1585
+ }
1586
+ .ds-thread-rich-composer .ProseMirror h1 {
1587
+ font-size: 18px;
1588
+ font-weight: 700;
1589
+ letter-spacing: -0.01em;
1590
+ }
1591
+ .ds-thread-rich-composer .ProseMirror h2 {
1592
+ font-size: 16px;
1593
+ font-weight: 700;
1594
+ }
1595
+ .ds-thread-rich-composer .ProseMirror h3 {
1596
+ font-size: 14px;
1597
+ font-weight: 600;
1598
+ }
1599
+ .ds-thread-rich-composer .ProseMirror ul,
1600
+ .ds-thread-rich-composer .ProseMirror ol {
1601
+ padding-inline-start: var(--space-5);
1602
+ }
1603
+ .ds-thread-rich-composer .ProseMirror ul {
1604
+ list-style: disc outside;
1605
+ }
1606
+ .ds-thread-rich-composer .ProseMirror ol {
1607
+ list-style: decimal outside;
1608
+ }
1609
+ .ds-thread-rich-composer .ProseMirror li + li {
1610
+ margin-block-start: var(--space-1);
1611
+ }
1612
+ .ds-thread-rich-composer .ProseMirror li > p + p {
1613
+ margin-block-start: var(--space-1);
1614
+ }
1615
+ .ds-thread-rich-composer .ProseMirror blockquote {
1616
+ /* Same rule width as the renderer's blockquote (`border-l-4`), on the scale. */
1617
+ border-inline-start: var(--space-1) solid rgb(var(--accent));
1618
+ padding-inline-start: var(--space-3);
1619
+ color: rgb(var(--muted-foreground));
1620
+ font-style: italic;
1621
+ }
1622
+ .ds-thread-rich-composer .ProseMirror code {
1623
+ font-family: var(--font-mono);
1624
+ font-size: 12px;
1625
+ background: rgb(var(--muted));
1626
+ border-radius: var(--radius-xs);
1627
+ padding-inline: var(--space-1);
1628
+ }
1629
+ .ds-thread-rich-composer .ProseMirror pre {
1630
+ font-family: var(--font-mono);
1631
+ font-size: 12px;
1632
+ line-height: 1.5;
1633
+ background: rgb(var(--surface-inset));
1634
+ border: 1px solid rgb(var(--border));
1635
+ border-radius: var(--radius-xs);
1636
+ padding: var(--space-2) var(--space-3);
1637
+ overflow-x: auto;
1638
+ white-space: pre;
1639
+ }
1640
+ .ds-thread-rich-composer .ProseMirror pre code {
1641
+ background: transparent;
1642
+ padding: 0;
1643
+ border-radius: 0;
1644
+ }
1645
+ .ds-thread-rich-composer .ProseMirror hr {
1646
+ border: 0;
1647
+ border-block-start: 1px solid rgb(var(--border));
1648
+ margin-block: var(--space-3);
1649
+ }
1650
+ .ds-thread-rich-composer .ProseMirror hr.ProseMirror-selectednode {
1651
+ border-block-start-color: rgb(var(--accent));
1652
+ }
1653
+ .ds-thread-rich-composer .ProseMirror a {
1654
+ color: rgb(var(--accent-ink));
1655
+ text-decoration: underline;
1656
+ text-underline-offset: 0.15em;
1657
+ }
1658
+ .ds-thread-rich-composer .ProseMirror s {
1659
+ color: rgb(var(--muted-foreground));
1660
+ }
1661
+ /* The mention chip, matching comment-body.tsx's rendered chip so what is typed
1662
+ and what is posted look the same. An atom: one selection, one delete. */
1663
+ .ds-thread-rich-composer .ProseMirror .ds-thread-mention {
1664
+ display: inline-flex;
1665
+ align-items: center;
1666
+ vertical-align: baseline;
1667
+ font-size: 12.5px;
1668
+ font-weight: 600;
1669
+ color: rgb(var(--accent-ink));
1670
+ background: rgb(var(--surface-inset));
1671
+ border: 1px solid rgb(var(--border));
1672
+ border-radius: var(--radius-sm);
1673
+ padding-inline: var(--space-1);
1674
+ white-space: nowrap;
1675
+ }
1676
+ .ds-thread-rich-composer .ProseMirror .ds-thread-mention.ProseMirror-selectednode {
1677
+ border-color: rgb(var(--accent));
1678
+ box-shadow: var(--ring-focus);
1679
+ }
1680
+
1681
+ /* The bubble menu (D7). floating-ui positions the outer element; this styles
1682
+ the toolbar inside it. Opaque `--surface-card` + border + `--shadow-lg`, the
1683
+ same recipe as Popover — never a translucent overlay token as a surface. */
1684
+ .ds-thread-bubble-menu {
1685
+ z-index: 20;
1686
+ transition: opacity var(--dur-fast) var(--ease-out);
1687
+ }
1688
+ .ds-thread-bubble-menu[data-open="false"] {
1689
+ opacity: 0;
1690
+ pointer-events: none;
1691
+ }
1692
+ .ds-thread-bubble-menu > [role="toolbar"] {
1693
+ display: inline-flex;
1694
+ align-items: center;
1695
+ gap: var(--space-1);
1696
+ padding: var(--space-1);
1697
+ background: rgb(var(--surface-card));
1698
+ border: 1px solid rgb(var(--border));
1699
+ border-radius: var(--radius-sm);
1700
+ box-shadow: var(--shadow-lg);
1701
+ }
1702
+ /* A PRESSED formatting button (bold is on, the selection is a link, …) steps
1703
+ up to an opaque surface with an accent border and `--accent-ink` glyph —
1704
+ never an alpha tint alone (CLAUDE.md §2, the SegmentedPill finding). */
1705
+ .ds-thread-rich-composer .btn[aria-pressed="true"],
1706
+ .ds-thread-bubble-menu .btn[aria-pressed="true"] {
1707
+ background: rgb(var(--surface-card));
1708
+ border-color: rgb(var(--accent));
1709
+ color: rgb(var(--accent-ink));
1710
+ box-shadow: var(--shadow-sm);
1711
+ }
1712
+ @media (prefers-reduced-motion: reduce) {
1713
+ .ds-thread-bubble-menu {
1714
+ transition: none;
1715
+ }
1716
+ }
1717
+
1718
+ /* ── THE SUGGESTION POPUP (ADR-149 D5) ───────────────────────────────────────
1719
+ *
1720
+ * ONE listbox, two triggers. The Suggestion plugin mounts and anchors the
1721
+ * ANCHOR element (floating-ui, already its own dependency) into the document
1722
+ * body, which is what lets the popup escape the thread's scroll clipping; this
1723
+ * styles the list inside it.
1724
+ *
1725
+ * Opaque `--surface-card` + border + `--shadow-lg`, the same recipe as Popover
1726
+ * and the bubble menu — never a translucent overlay token as a surface
1727
+ * (CLAUDE.md §2). The active row steps up to a flat OPAQUE `--muted` with an
1728
+ * accent bar down its inline start, so the state is never carried by alpha or
1729
+ * by colour alone.
1730
+ *
1731
+ * `overscroll-behavior: contain` is load-bearing, not decoration: this popup
1732
+ * appears WHILE someone is typing, and a wheel or a trackpad flick that
1733
+ * chained out of it would scroll the page under the caret — the defect this
1734
+ * codebase has already fixed twice in the composer. The keyboard path is
1735
+ * closed the same way, in `suggestion-list.tsx`: the active option is brought
1736
+ * into view by arithmetic on this element's own `scrollTop`, never
1737
+ * `scrollIntoView`, which walks up and scrolls every ancestor it can.
1738
+ */
1739
+ .ds-thread-suggestions-anchor {
1740
+ /* Positioned HERE, not only by floating-ui. The plugin writes `left`/`top`
1741
+ when `computePosition()` resolves, which is a promise: for the frame
1742
+ before it does, an unpositioned block appended to <body> would sit at the
1743
+ end of the document and grow the page. Absolute + a 0,0 origin makes that
1744
+ frame a no-op instead. */
1745
+ position: absolute;
1746
+ inset-block-start: 0;
1747
+ inset-inline-start: 0;
1748
+ z-index: 30;
1749
+ inline-size: max-content;
1750
+ }
1751
+ .ds-thread-suggestions {
1752
+ display: block;
1753
+ min-inline-size: 15rem;
1754
+ max-inline-size: 22rem;
1755
+ /* Eight rows plus the padding; past that the popup scrolls, and only it. */
1756
+ max-block-size: calc(8 * var(--ctrl-sm) + 2 * var(--space-1));
1757
+ overflow-y: auto;
1758
+ overscroll-behavior: contain;
1759
+ padding: var(--space-1);
1760
+ background: rgb(var(--surface-card));
1761
+ border: 1px solid rgb(var(--border));
1762
+ border-radius: var(--radius-sm);
1763
+ box-shadow: var(--shadow-lg);
1764
+ }
1765
+ .ds-thread-suggestions__option {
1766
+ display: flex;
1767
+ align-items: center;
1768
+ gap: var(--space-2);
1769
+ inline-size: 100%;
1770
+ /* The touch floor. Also the row height the max-block-size above counts. */
1771
+ min-block-size: var(--ctrl-sm);
1772
+ padding-inline: var(--space-2);
1773
+ border: 0;
1774
+ border-radius: var(--radius-xs);
1775
+ background: transparent;
1776
+ color: rgb(var(--foreground));
1777
+ font-family: var(--font-sans);
1778
+ font-size: 13px;
1779
+ line-height: 1.4;
1780
+ text-align: start;
1781
+ cursor: pointer;
1782
+ }
1783
+ .ds-thread-suggestions__option[data-active="true"] {
1784
+ background: rgb(var(--muted));
1785
+ box-shadow: inset 2px 0 0 0 rgb(var(--accent));
1786
+ }
1787
+ /* A reserved, centred slot: the glyph column must not resize the row when a
1788
+ two-cell emoji follows a one-letter initial. */
1789
+ .ds-thread-suggestions__glyph {
1790
+ flex: none;
1791
+ inline-size: var(--space-5);
1792
+ font-size: 15px;
1793
+ line-height: 1;
1794
+ text-align: center;
1795
+ color: rgb(var(--text-secondary));
1796
+ }
1797
+ .ds-thread-suggestions__label {
1798
+ flex: 1 1 auto;
1799
+ min-inline-size: 0;
1800
+ overflow: hidden;
1801
+ white-space: nowrap;
1802
+ text-overflow: ellipsis;
1803
+ }
1804
+ .ds-thread-suggestions__hint {
1805
+ flex: none;
1806
+ font-size: 11px;
1807
+ color: rgb(var(--text-tertiary));
1808
+ }
1809
+
1810
+ /* ── ITEM DETAIL — the field list's empty state ──────────────────────────────
1811
+ *
1812
+ * `docs/design-briefs/tasks-item-detail-full.md` §4: the field search and the
1813
+ * All / Filled / Empty chips filter the visible rows. A row that does not match
1814
+ * stays MOUNTED and carries `hidden` — unmounting it would throw away a
1815
+ * half-finished edit on the next keystroke in the search box.
1816
+ *
1817
+ * Which leaves "are any rows left?" to answer, and `:has()` answers it in the
1818
+ * one place that already knows: the browser. The alternative — every row
1819
+ * registering itself with `<FieldList>` in an effect so React can count the
1820
+ * matches — is a render-loop hazard for a single line of muted text.
1821
+ */
1822
+ .ds-field-list {
1823
+ /* The shared value column. Declared HERE with its default, and overridden
1824
+ inline only when a host passes `labelWidth` — an override knob declares
1825
+ itself and its default rather than living as a bare `var(--x, 132px)`
1826
+ fallback that a typo could hide behind forever (the package's own
1827
+ token-shape rule, and `--board-column-max-block-size` above it). Every
1828
+ row reads it, so the values line up down the list and no row can drift. */
1829
+ --field-label-inline-size: 132px;
1830
+ }
1831
+ .ds-field-list-empty {
1832
+ display: none;
1833
+ }
1834
+ .ds-field-list:not(:has([data-field-row]:not([hidden]))) .ds-field-list-empty {
1835
+ display: block;
1836
+ }
1837
+
1838
+ /* ── ITEM DETAIL — the field row's box, and how it gives way ─────────────────
1839
+ *
1840
+ * The row's gap, label width and direction live HERE rather than on the
1841
+ * element, and that is the whole reason the narrow layout works: an inline
1842
+ * `style` beats a container query, so nothing a band needs to change can be
1843
+ * written in the JSX.
1844
+ *
1845
+ * The defect this answers: the fields pane had no minimum, so dragging the
1846
+ * divider narrow squeezed the value column to nothing and every value wrapped
1847
+ * to about four characters — "Aug 22, 202", "Set star date". `DetailSurface`
1848
+ * now clamps the split so the pane cannot go below 288px, and these bands are
1849
+ * the other half — what the row does with the room it is given.
1850
+ *
1851
+ * WHICH COLUMN YIELDS. The label, first and deliberately. It is a short, fixed
1852
+ * vocabulary the operator has already read and is scanning by icon and
1853
+ * position — "Due date", "Assignee", "Priority" — while the value is the
1854
+ * unknown content the row exists to show. So the label tightens from 132 to
1855
+ * 104 before the value loses a pixel, and when even that is not enough the row
1856
+ * STACKS rather than fighting for a horizontal label column at all.
1857
+ *
1858
+ * THE TWO NUMBERS. Breakpoints, not spacing — they are not on the `--space-N`
1859
+ * scale because they are not a gap, and the arithmetic behind each is:
1860
+ *
1861
+ * 30rem / 480px tighten the label. Above it there is room for 132.
1862
+ * 25rem / 400px stack. At exactly 400 the row is still side by side and the
1863
+ * value column is 400 − 32 (the list's `--space-4` padding)
1864
+ * − 104 (label) − 8 (gap) = 256px. Below it the value takes
1865
+ * the full 256+ on its own line. 256px is therefore the floor
1866
+ * from BOTH directions, and `FIELDS_MIN` in
1867
+ * `detail-surface.tsx` is set to 288 to keep it there.
1868
+ */
1869
+ .ds-field-list {
1870
+ container-type: inline-size;
1871
+ container-name: ds-field-list;
1872
+ }
1873
+
1874
+ [data-field-row] {
1875
+ gap: var(--space-2);
1876
+ min-block-size: var(--ctrl-sm);
1877
+ /* The list is a flex COLUMN, and a flex item shrinks below its content
1878
+ before its scroller ever gets anything to scroll. Single-line rows hid
1879
+ that — they wanted exactly the 32px floor above — but a stacked row wants
1880
+ roughly 52, shrank to 32, and its value overlapped the next row's label.
1881
+ A scrolling list's items do not shrink. */
1882
+ flex-shrink: 0;
1883
+ }
1884
+ [data-field-row] > [data-slot='field-label'] {
1885
+ gap: var(--space-2);
1886
+ inline-size: var(--field-label-inline-size, 132px);
1887
+ padding-block: var(--space-2);
1888
+ }
1889
+
1890
+ @container ds-field-list (max-width: 30rem) {
1891
+ [data-field-row] > [data-slot='field-label'] {
1892
+ inline-size: 104px;
1893
+ }
1894
+ }
1895
+
1896
+ /* Ordered AFTER the band above on purpose: both match under 400px, and the
1897
+ stacked rule has to be the one that lands. */
1898
+ @container ds-field-list (max-width: 25rem) {
1899
+ /* Doubled to out-specify the element's own `items-start` utility. */
1900
+ [data-field-row][data-field-row] {
1901
+ flex-direction: column;
1902
+ align-items: stretch;
1903
+ gap: var(--space-1);
1904
+ }
1905
+ [data-field-row] > [data-slot='field-label'] {
1906
+ inline-size: 100%;
1907
+ padding-block: var(--space-1) 0;
1908
+ }
1909
+ [data-field-row] > [data-slot='field-value-col'] {
1910
+ inline-size: 100%;
1911
+ }
1912
+ }
1913
+
1914
+ /* ── ITEM DETAIL — the split, and collapsing a pane ──────────────────────────
1915
+ *
1916
+ * The clip boxes carry the size change, so they are what animates. Not during
1917
+ * a DRAG: the pointer sets the width every frame and a 240ms tween would run
1918
+ * a quarter-second behind the cursor. `:has()` reads the handle's own
1919
+ * `data-dragging`, so there is no second copy of "is the operator dragging"
1920
+ * for the two to disagree about.
1921
+ */
1922
+ [data-slot='detail-split'] [data-detail-clip] {
1923
+ transition:
1924
+ inline-size var(--dur-base) var(--ease-out),
1925
+ block-size var(--dur-base) var(--ease-out);
1926
+ }
1927
+ [data-slot='detail-split']:has([data-resizable-handle][data-dragging])
1928
+ [data-detail-clip] {
1929
+ transition: none;
1930
+ }
1931
+ /* The PANEL's own leading edge lives outside the split, so the rule above
1932
+ cannot see it — and it must, because the fields pane is `flex: 1 1 0%` and
1933
+ its inline size therefore follows the panel's width. Without this the pane
1934
+ tweens 240ms behind the pointer for the whole drag. */
1935
+ [data-slot='detail-panel']:has(
1936
+ [data-slot='detail-panel-resize'] [data-resizable-handle][data-dragging]
1937
+ )
1938
+ [data-detail-clip] {
1939
+ transition: none;
1940
+ }
1941
+
1942
+ /* The collapse capsule. Revealed by hover on the seam or focus inside it —
1943
+ an `opacity: 0` button is still TABBABLE, which is what lets a keyboard user
1944
+ reach it at all, and `:focus-within` is what makes it visible when they do.
1945
+ `pointer-events` is the half that must NOT stay on: an invisible 24px target
1946
+ sitting over both panes would eat clicks meant for the content. */
1947
+ [data-slot='detail-collapse-capsule'] {
1948
+ opacity: 0;
1949
+ pointer-events: none;
1950
+ transition: opacity var(--dur-fast) var(--ease-out);
1951
+ }
1952
+ [data-slot='detail-divider']:hover [data-slot='detail-collapse-capsule'],
1953
+ [data-slot='detail-divider']:focus-within [data-slot='detail-collapse-capsule'] {
1954
+ opacity: 1;
1955
+ pointer-events: auto;
1956
+ }
1957
+
1958
+ @media (prefers-reduced-motion: reduce) {
1959
+ [data-slot='detail-split'] [data-detail-clip],
1960
+ [data-slot='detail-collapse-capsule'] {
1961
+ transition: none;
1962
+ }
1963
+ }
1964
+
1965
+ /* ── ITEM DETAIL — printing one record ───────────────────────────────────────
1966
+ *
1967
+ * §3 lists `Print`, and §7 says it is the one control on that list that CAN be
1968
+ * honest: "the browser has one, and a print stylesheet is honest work". This is
1969
+ * that work. Without it `window.print()` prints the BOARD with a fixed,
1970
+ * viewport-height panel clipped over it — the record's fields and its whole
1971
+ * conversation cut off at the fold — which is a control that runs and produces
1972
+ * the wrong thing, and that is the same defect as one that does nothing.
1973
+ *
1974
+ * The mechanism is the classic print-one-element trick: hide everything, then
1975
+ * un-hide the open detail and its subtree. `visibility` rather than `display`
1976
+ * because it leaves the ancestors in the layout, so the panel is still
1977
+ * positioned by the same boxes it renders in on screen.
1978
+ *
1979
+ * Then the panel stops being a window: no viewport clamp, no fixed height, no
1980
+ * inner scrollers, and no chrome that only makes sense on a screen — the
1981
+ * backdrop, the resize handle, the composer, and the field list's own search
1982
+ * and filter row, which describe how to look at the record rather than what it
1983
+ * says. The header stays: it carries the title, the completion state and when
1984
+ * the record was last touched.
1985
+ */
1986
+ @media print {
1987
+ /* The app shell is a viewport-height flex column that never scrolls — the
1988
+ board's height rule. On paper there is no viewport, so those heights have
1989
+ to be released or the record is cut off at one page. */
1990
+ html:has([data-slot='detail-surface'][data-state='open']),
1991
+ body:has([data-slot='detail-surface'][data-state='open']) {
1992
+ block-size: auto !important;
1993
+ max-block-size: none !important;
1994
+ overflow: visible !important;
1995
+ }
1996
+ body:has([data-slot='detail-surface'][data-state='open']) {
1997
+ visibility: hidden;
1998
+ /* The containing block for the record below. Without it the record's
1999
+ `position: absolute` resolves against whichever ancestor happens to be
2000
+ positioned — which is a different box in each mode, and lands the page
2001
+ at an arbitrary offset. */
2002
+ position: relative;
2003
+ }
2004
+ body:has([data-slot='detail-surface'][data-state='open'])
2005
+ [data-slot='detail-surface'][data-state='open'] {
2006
+ position: absolute;
2007
+ inset-block-start: 0;
2008
+ inset-inline-start: 0;
2009
+ /* `inset-0` on the wrapper sets a bottom edge too, which pins the record's
2010
+ height to the viewport and clips a long one at the fold. Releasing the
2011
+ trailing edges is what lets it be as tall as it is. */
2012
+ inset-block-end: auto;
2013
+ inset-inline-end: auto;
2014
+ inline-size: 100%;
2015
+ block-size: auto;
2016
+ padding: 0 !important;
2017
+ visibility: visible;
2018
+ /* Not a centring flex box on paper. Centred, the record floats in the
2019
+ middle of a viewport-height frame and pages break in the wrong place;
2020
+ as a block it starts at the top of page one and flows. */
2021
+ display: block !important;
2022
+ }
2023
+ body:has([data-slot='detail-surface'][data-state='open'])
2024
+ [data-slot='detail-surface'][data-state='open'] * {
2025
+ visibility: visible;
2026
+ }
2027
+
2028
+ [data-slot='detail-panel'] {
2029
+ max-inline-size: none !important;
2030
+ inline-size: 100% !important;
2031
+ block-size: auto !important;
2032
+ overflow: visible !important;
2033
+ border: 0 !important;
2034
+ border-radius: 0 !important;
2035
+ box-shadow: none !important;
2036
+ opacity: 1 !important;
2037
+ transform: none !important;
2038
+ }
2039
+ /* Every scroller inside the record prints in full. */
2040
+ /* `[data-detail-clip]` is the box a collapse takes to zero. On paper a
2041
+ collapsed pane is not a layout choice, it is half the record missing, so
2042
+ printing releases it — the same reasoning that releases the scrollers. */
2043
+ [data-slot='detail-panel'] [data-slot='detail-split'],
2044
+ [data-slot='detail-panel'] [data-detail-clip],
2045
+ [data-slot='detail-panel'] [data-slot='detail-fields'],
2046
+ [data-slot='detail-panel'] [data-slot='detail-activity'],
2047
+ [data-slot='detail-panel'] [data-slot='field-list-body'],
2048
+ [data-slot='detail-panel'] [role='tabpanel'] {
2049
+ overflow: visible !important;
2050
+ block-size: auto !important;
2051
+ inline-size: auto !important;
2052
+ min-block-size: 0 !important;
2053
+ min-inline-size: 0 !important;
2054
+ flex: none !important;
2055
+ }
2056
+ [data-slot='detail-panel'] [data-slot='detail-split'] {
2057
+ display: block !important;
2058
+ }
2059
+
2060
+ /* Screen-only chrome. */
2061
+ [data-slot='detail-backdrop'],
2062
+ [data-slot='detail-divider'],
2063
+ [data-slot='detail-restore'],
2064
+ [data-slot='activity-composer'],
2065
+ [data-slot='detail-menu-slot'],
2066
+ [data-slot='field-list'] > div:first-child {
2067
+ display: none !important;
2068
+ }
2069
+ /* The header's icon-only controls — step, star, close. They are ways to
2070
+ NAVIGATE the record, and on paper they print as empty boxes. Selected by
2071
+ `aria-label`, which is exactly the set that is icon-only: mark-complete
2072
+ carries its label as visible text because that label is also the state
2073
+ readout, and it stays. */
2074
+ [data-slot='detail-header'] button[aria-label] {
2075
+ display: none !important;
2076
+ }
2077
+ }
2078
+
2079
+ /* ─────────────────────────────────────────────────────────────────────────
2080
+ * FORCED COLORS (Windows High Contrast Mode)
2081
+ *
2082
+ * Forced-colors mode replaces every author colour with a small system
2083
+ * palette and, critically, FORCES `box-shadow: none`. Almost every focus
2084
+ * indicator in this package is authored as
2085
+ *
2086
+ * focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]
2087
+ *
2088
+ * so in HCM the outline is switched off by the author and the box-shadow is
2089
+ * switched off by the platform: measured live, four focused controls all
2090
+ * reported `outline-style: none; box-shadow: none`. A keyboard user in HCM
2091
+ * had NO focus indicator anywhere in the app.
2092
+ *
2093
+ * The same erasure takes out the rest of the depth vocabulary — shadows,
2094
+ * translucent fills, hue tints — so the structure those carry has to come
2095
+ * back as borders/outlines in SYSTEM colours (`CanvasText`, `ButtonBorder`,
2096
+ * `GrayText`, `Highlight`). System colour keywords are the correct answer
2097
+ * here and are not colour literals: a hardcoded hex would be exactly the
2098
+ * thing forced-colors exists to override.
2099
+ *
2100
+ * `forced-color-adjust: none` (opt an element OUT of the substitution) is
2101
+ * used in ONE place below, and only where the colour IS the content.
2102
+ * ───────────────────────────────────────────────────────────────────────── */
2103
+ @media (forced-colors: active) {
2104
+ /* ---- 1. THE FOCUS INDICATOR --------------------------------------------
2105
+ *
2106
+ * SPECIFICITY NOTE. This has to beat Tailwind's `focus-visible:outline-none`
2107
+ * utility, which is emitted as `.focus-visible\:outline-none:focus-visible`
2108
+ * — a class plus a pseudo-class, i.e. (0,2,0). `*:focus-visible` is (0,1,0)
2109
+ * and would lose; so would the `@layer base` `:where(...)` baseline higher
2110
+ * up this file, which is deliberately (0,0,0). Rather than chase specificity
2111
+ * with a manufactured selector (and lose again the next time a component
2112
+ * adds its own `focus-visible:` class), `!important` is used — but ONLY on
2113
+ * the three outline properties. Nothing else here is `!important`, so this
2114
+ * cannot become the "wins everything" rule that gets copied around.
2115
+ *
2116
+ * `Highlight` is the system's selection/focus colour, which is what HCM
2117
+ * users are calibrated to look for. `outline-offset` keeps the ring off the
2118
+ * control's own forced border so the two do not read as one thick edge. */
2119
+ *:focus-visible {
2120
+ outline: 2px solid Highlight !important;
2121
+ outline-offset: 2px !important;
2122
+ }
2123
+
2124
+ /* ---- 2. THE TWO EXISTING RING RULES ------------------------------------
2125
+ *
2126
+ * `.ds-board-hidden-rail-head:focus-visible` (the hidden-columns
2127
+ * disclosure) and `.ds-hue-swatch:focus-visible` are the two places this
2128
+ * file authors a ring itself. Both are `outline: none` + box-shadow, so
2129
+ * both are covered by rule 1 above — this rule only neutralises their
2130
+ * `outline: none`, which rule 1's `!important` already does. What is NOT
2131
+ * covered is the hue swatch's SELECTED halo, which is a box-shadow and not
2132
+ * a focus state: see rule 5. */
2133
+
2134
+ /* ---- 3. CARD / PAGE SEPARATION -----------------------------------------
2135
+ *
2136
+ * Every surface below separates from what is behind it with a background
2137
+ * step plus a shadow. In HCM the step collapses (all backgrounds become
2138
+ * `Canvas`) and the shadow is dropped, so a board reads as one flat field
2139
+ * of text with no cards and no lanes in it.
2140
+ *
2141
+ * These are declared explicitly rather than relied upon: most of them do
2142
+ * carry a 1px border already, but `.ds-board-card`'s is an INLINE style on
2143
+ * task-card.tsx and `--border-card` is a theme token that a future theme is
2144
+ * free to make transparent — a transparent border is not forced into
2145
+ * visibility, so the separation would silently vanish again. `ButtonBorder`
2146
+ * is the system's "edge of a thing" colour and reads as structure rather
2147
+ * than as text. */
2148
+ .ds-card-surface,
2149
+ .ds-card-tray,
2150
+ .ds-board-card,
2151
+ .ds-board-column,
2152
+ .ds-board-hidden-rail,
2153
+ .ds-board-hidden-row {
2154
+ border: 1px solid ButtonBorder;
2155
+ }
2156
+
2157
+ /* ---- 4. THE STATUS RING ------------------------------------------------
2158
+ *
2159
+ * `StatusRing` is two concentric `<circle>` strokes: a `--border` track and
2160
+ * a hue arc whose LENGTH is the percentage. `stroke` is one of the forced
2161
+ * properties, so in HCM both circles paint in the same colour and the ring
2162
+ * reads as a solid, meaningless "100%" — the one number it exists to show
2163
+ * is the one that disappears.
2164
+ *
2165
+ * Restored with system colours rather than `forced-color-adjust: none`,
2166
+ * because the arc's length already carries the meaning; it only needs the
2167
+ * track to stop impersonating it. The track drops to `GrayText` (the
2168
+ * system's de-emphasised rung) and the arc takes `CanvasText`.
2169
+ *
2170
+ * `circle + circle` is the arc: CompletionRing renders the track first and
2171
+ * the arc second, and stroke is set there as an SVG presentation attribute,
2172
+ * which any CSS rule outranks. */
2173
+ .ds-status-ring circle {
2174
+ stroke: GrayText;
2175
+ }
2176
+ .ds-status-ring circle + circle {
2177
+ stroke: CanvasText;
2178
+ }
2179
+
2180
+ /* ---- 5. THE HUE SWATCH ------------------------------------------------
2181
+ *
2182
+ * THE ONE `forced-color-adjust: none` IN THIS FILE, and the reason is that
2183
+ * this control's entire job is to show a colour. The column-colour
2184
+ * radiogroup asks "which hue is this lane" and answers with the fill and
2185
+ * nothing else — there is no glyph, no pattern, no second cue by design
2186
+ * (see the HUE SWATCH block above: a tick would need its own contrast
2187
+ * budget on top of a saturated fill). Forced into the system palette, nine
2188
+ * swatches become nine identical squares and the control is unusable.
2189
+ *
2190
+ * Opting out also preserves the SELECTED halo, which is a box-shadow
2191
+ * (`--surface-card` gap + `--accent` ring) and would otherwise be dropped,
2192
+ * leaving the chosen hue unmarked. Focus is unaffected: rule 1's outline is
2193
+ * authored here, not substituted, so `Highlight` still resolves.
2194
+ *
2195
+ * Note that the hue is NOT the swatch's only cue for assistive tech — the
2196
+ * accessible name is the hue's name and `aria-checked` carries selection.
2197
+ * This is a purely visual opt-out for a purely visual control. */
2198
+ .ds-hue-swatch {
2199
+ forced-color-adjust: none;
2200
+ }
2201
+
2202
+ /* ---- 6. THE LANE HUE DOT ----------------------------------------------
2203
+ *
2204
+ * `.ds-board-column-dot` is an 8px square filled with the lane's hue and
2205
+ * nothing else, so in HCM it becomes an invisible hole beside the label.
2206
+ * It gets a border rather than `forced-color-adjust: none` on purpose: the
2207
+ * dot is `aria-hidden` decoration sitting immediately before the lane's own
2208
+ * text label, so the hue is redundant information and does not earn an
2209
+ * opt-out. What it does need is to still read as a marker. */
2210
+ .ds-board-column-dot {
2211
+ border: 1px solid CanvasText;
2212
+ }
2213
+
2214
+ /* ---- 7. LEDGER CELLS ---------------------------------------------------
2215
+ *
2216
+ * `.ds-ledger-cell` encodes done / current / pending as three background
2217
+ * fills (`--tint-fill`, `--tint-task`, `--surface-card`) with no border and
2218
+ * no glyph. Forced to `Canvas`, the whole ledger becomes one blank bar: the
2219
+ * cells stop being distinguishable from each other AND from the run they
2220
+ * sit in.
2221
+ *
2222
+ * The separation is restored with an inset `CanvasText` hairline, so the
2223
+ * cells at least read as discrete cells. The done/pending DISTINCTION is
2224
+ * not recoverable from CSS alone — it lives in an inline `background` on
2225
+ * each cell with no data attribute to select on, and progress-ledger.tsx
2226
+ * was out of scope for this pass. The non-visual cue does exist (the run's
2227
+ * accessible name is "<label>: N of M done"), but a sighted HCM user still
2228
+ * cannot see WHICH cells are done. Logged as a follow-up: the fix is a
2229
+ * `data-state="done|current|pending"` attribute on the cell, at which point
2230
+ * `forced-color-adjust: none` here (or a `Highlight` fill for `done`) would
2231
+ * carry it. */
2232
+ .ds-ledger-cell {
2233
+ outline: 1px solid CanvasText;
2234
+ outline-offset: -1px;
2235
+ }
2236
+
2237
+ /* ---- 8. THE OPTION ROW'S BORROWED RING ---------------------------------
2238
+ *
2239
+ * `ToggleRow` paints the focus ring on the ROW because its checkbox input
2240
+ * is `opacity: 0` — an outline on the input itself would be invisible. That
2241
+ * ring is a box-shadow, so it is dropped here, and rule 1 lands the system
2242
+ * outline on the invisible input where nobody can see it. The row takes the
2243
+ * indicator instead. */
2244
+ [data-slot='option-row']:has(:focus-visible) {
2245
+ outline: 2px solid Highlight;
2246
+ outline-offset: 2px;
2247
+ }
2248
+ }