@lovett/ui 0.0.11 → 0.1.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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5402 -71
- package/dist/index.js +21992 -11618
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +208 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +186 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +507 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +242 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +296 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +662 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +915 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +218 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
package/dist/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
|
+
}
|