@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
@@ -0,0 +1,316 @@
1
+ /**
2
+ * useThread — the headless core. It renders nothing.
3
+ *
4
+ * ADR-147 T3. Everything that is genuinely thread LOGIC lives here: building
5
+ * the tree, sorting it, collapse state, the depth cap, "show N more replies",
6
+ * the root draft, and which comment (if any) has an open reply composer.
7
+ * `<Thread>` is one skin over this; a consumer that wants different chrome
8
+ * uses the hook and draws its own.
9
+ *
10
+ * What this hook deliberately does NOT own:
11
+ *
12
+ * • No fetch client, no base URL, no query key, no brand id. It takes
13
+ * already-shaped data and reports intent through callbacks. T4 wires it.
14
+ * • No persistence. The draft is in-memory here; ADR-147 D17 routes it
15
+ * through `useResumableDraft` with a brand-partitioned key at the call
16
+ * site, because a comment draft is EDITOR state — a bare `key={brand.id}`
17
+ * on the thread would destroy an unsent comment on a brand switch.
18
+ * • No reaction or vote state. Correction 3 to the port: the reference held
19
+ * vote state in local `useState`, so a vote was lost the moment the dialog
20
+ * closed. Engagement is props in, callbacks out, always.
21
+ *
22
+ * Every piece of local state here is genuinely local VIEW state — what is
23
+ * collapsed, what is expanded, what is being typed — and losing it on unmount
24
+ * is correct. That is the line.
25
+ */
26
+
27
+ import { useCallback, useMemo, useState } from 'react'
28
+ import { buildCommentTree, countCommentTree, sortCommentTree } from './comment-tree'
29
+ import { useNowTick } from './use-now'
30
+ import type {
31
+ CommentNode,
32
+ ThreadComment,
33
+ ThreadEngagement,
34
+ ThreadSort,
35
+ } from './types'
36
+
37
+ /**
38
+ * Nesting is UNCAPPED by default.
39
+ *
40
+ * The port inherited a cap of 2 plus "Continue this thread", and that cap was
41
+ * paying for a rendering problem rather than solving a reading one: when every
42
+ * level cost a 24px gutter and its own collapse button, depth was expensive and
43
+ * had to be rationed. Depth is now an indent and a hairline (rail.tsx), so it
44
+ * is cheap, and a dead-end control that hides half a conversation behind a
45
+ * click is a worse answer than simply showing it. The INDENT is what gets
46
+ * clamped instead (`indentClampDepth`), which is the thing that actually breaks
47
+ * in a narrow column.
48
+ *
49
+ * The cap machinery stays for a consumer that genuinely wants one — a preview
50
+ * strip on a card, a permalink view that pages by subtree. Set a finite
51
+ * `maxDepth` and "Continue this thread" comes back.
52
+ */
53
+ export const DEFAULT_MAX_DEPTH = Number.POSITIVE_INFINITY
54
+ /** Three replies visible, then "Show N more replies". */
55
+ export const DEFAULT_REPLIES_VISIBLE = 3
56
+
57
+ export interface UseThreadOptions {
58
+ readonly comments: readonly ThreadComment[]
59
+ /**
60
+ * Epoch ms. Omit and the hook subscribes to the shared 60s ticker; pass one
61
+ * and the clock is frozen and no timer runs (tests, SSR, a replay view).
62
+ */
63
+ readonly now?: number | undefined
64
+ /** Controlled sort. Omit for uncontrolled. */
65
+ readonly sort?: ThreadSort | undefined
66
+ readonly defaultSort?: ThreadSort | undefined
67
+ readonly onSortChange?: ((sort: ThreadSort) => void) | undefined
68
+ /** Render depth cap. Storage depth is always true (D3). */
69
+ readonly maxDepth?: number | undefined
70
+ readonly repliesVisible?: number | undefined
71
+ readonly engagement?: ThreadEngagement | undefined
72
+ /** Controlled collapse set. Omit for uncontrolled. */
73
+ readonly collapsed?: readonly string[] | undefined
74
+ readonly onCollapse?: ((commentId: string, collapsed: boolean) => void) | undefined
75
+ /** Controlled root draft — the seam T4 hands `useResumableDraft`. */
76
+ readonly draft?: string | undefined
77
+ readonly defaultDraft?: string | undefined
78
+ readonly onDraftChange?: ((value: string) => void) | undefined
79
+ /**
80
+ * Verified attachment ticket ids are the third argument so the existing
81
+ * `(body, parentId?)` seam stays source-compatible (ADR-151 D1).
82
+ */
83
+ readonly onSubmit?:
84
+ | ((body: string, parentId?: string, attachmentIds?: readonly string[]) => void)
85
+ | undefined
86
+ }
87
+
88
+ export interface VisibleReplies {
89
+ readonly visible: readonly CommentNode[]
90
+ readonly hidden: number
91
+ }
92
+
93
+ export interface UseThreadResult {
94
+ readonly now: number
95
+ readonly roots: readonly CommentNode[]
96
+ /** Every comment at every depth, tombstones included. */
97
+ readonly totalCount: number
98
+ readonly maxDepth: number
99
+ readonly repliesVisible: number
100
+ readonly engagement: ThreadEngagement
101
+
102
+ readonly sort: ThreadSort
103
+ readonly setSort: (sort: ThreadSort) => void
104
+
105
+ readonly isCollapsed: (commentId: string) => boolean
106
+ readonly setCollapsed: (commentId: string, collapsed: boolean) => void
107
+ readonly toggleCollapse: (commentId: string) => void
108
+
109
+ readonly isRepliesExpanded: (commentId: string) => boolean
110
+ readonly expandReplies: (commentId: string) => void
111
+ readonly visibleReplies: (node: CommentNode) => VisibleReplies
112
+
113
+ /** Whether the surface accepts new comments at all (an `onSubmit` exists). */
114
+ readonly canReply: boolean
115
+ readonly draft: string
116
+ readonly setDraft: (value: string) => void
117
+ readonly canSubmit: boolean
118
+ readonly submit: (attachmentIds?: readonly string[]) => void
119
+
120
+ readonly replyingTo: string | null
121
+ readonly isReplyingTo: (commentId: string) => boolean
122
+ readonly startReply: (commentId: string) => void
123
+ readonly cancelReply: () => void
124
+ readonly replyDraft: string
125
+ readonly setReplyDraft: (value: string) => void
126
+ readonly canSubmitReply: boolean
127
+ readonly submitReply: (attachmentIds?: readonly string[]) => void
128
+ }
129
+
130
+ export function useThread(options: UseThreadOptions): UseThreadResult {
131
+ const {
132
+ comments,
133
+ now: injectedNow,
134
+ sort: controlledSort,
135
+ defaultSort = 'top',
136
+ onSortChange,
137
+ maxDepth = DEFAULT_MAX_DEPTH,
138
+ repliesVisible = DEFAULT_REPLIES_VISIBLE,
139
+ engagement = 'reactions',
140
+ collapsed: controlledCollapsed,
141
+ onCollapse,
142
+ draft: controlledDraft,
143
+ defaultDraft = '',
144
+ onDraftChange,
145
+ onSubmit,
146
+ } = options
147
+
148
+ const tick = useNowTick(injectedNow === undefined)
149
+ const now = injectedNow ?? tick
150
+
151
+ const [uncontrolledSort, setUncontrolledSort] = useState<ThreadSort>(defaultSort)
152
+ const sort = controlledSort ?? uncontrolledSort
153
+
154
+ const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState<ReadonlySet<string>>(
155
+ () => new Set<string>(),
156
+ )
157
+ const collapsedSet = useMemo(
158
+ () =>
159
+ controlledCollapsed === undefined
160
+ ? uncontrolledCollapsed
161
+ : new Set(controlledCollapsed),
162
+ [controlledCollapsed, uncontrolledCollapsed],
163
+ )
164
+
165
+ const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set<string>())
166
+
167
+ const [uncontrolledDraft, setUncontrolledDraft] = useState(defaultDraft)
168
+ const draft = controlledDraft ?? uncontrolledDraft
169
+
170
+ const [replyingTo, setReplyingTo] = useState<string | null>(null)
171
+ const [replyDraft, setReplyDraft] = useState('')
172
+
173
+ const tree = useMemo(() => buildCommentTree(comments), [comments])
174
+ const roots = useMemo(() => sortCommentTree(tree, sort), [tree, sort])
175
+ const totalCount = useMemo(() => countCommentTree(tree), [tree])
176
+
177
+ const setSort = useCallback(
178
+ (next: ThreadSort) => {
179
+ if (controlledSort === undefined) setUncontrolledSort(next)
180
+ onSortChange?.(next)
181
+ },
182
+ [controlledSort, onSortChange],
183
+ )
184
+
185
+ const isCollapsed = useCallback(
186
+ (commentId: string) => collapsedSet.has(commentId),
187
+ [collapsedSet],
188
+ )
189
+
190
+ const setCollapsed = useCallback(
191
+ (commentId: string, next: boolean) => {
192
+ if (controlledCollapsed === undefined) {
193
+ setUncontrolledCollapsed((prev) => {
194
+ if (prev.has(commentId) === next) return prev
195
+ const copy = new Set(prev)
196
+ if (next) copy.add(commentId)
197
+ else copy.delete(commentId)
198
+ return copy
199
+ })
200
+ }
201
+ onCollapse?.(commentId, next)
202
+ },
203
+ [controlledCollapsed, onCollapse],
204
+ )
205
+
206
+ const toggleCollapse = useCallback(
207
+ (commentId: string) => {
208
+ setCollapsed(commentId, !collapsedSet.has(commentId))
209
+ },
210
+ [collapsedSet, setCollapsed],
211
+ )
212
+
213
+ const isRepliesExpanded = useCallback(
214
+ (commentId: string) => expanded.has(commentId),
215
+ [expanded],
216
+ )
217
+
218
+ const expandReplies = useCallback((commentId: string) => {
219
+ setExpanded((prev) => {
220
+ if (prev.has(commentId)) return prev
221
+ const copy = new Set(prev)
222
+ copy.add(commentId)
223
+ return copy
224
+ })
225
+ }, [])
226
+
227
+ const visibleReplies = useCallback(
228
+ (node: CommentNode): VisibleReplies => {
229
+ const all = node.replies
230
+ if (expanded.has(node.id) || all.length <= repliesVisible) {
231
+ return { visible: all, hidden: 0 }
232
+ }
233
+ const visible = all.slice(0, repliesVisible)
234
+ return { visible, hidden: all.length - visible.length }
235
+ },
236
+ [expanded, repliesVisible],
237
+ )
238
+
239
+ const setDraft = useCallback(
240
+ (value: string) => {
241
+ if (controlledDraft === undefined) setUncontrolledDraft(value)
242
+ onDraftChange?.(value)
243
+ },
244
+ [controlledDraft, onDraftChange],
245
+ )
246
+
247
+ const canReply = onSubmit !== undefined
248
+ const canSubmit = draft.trim() !== '' && canReply
249
+
250
+ const submit = useCallback((attachmentIds?: readonly string[]) => {
251
+ const body = draft.trim()
252
+ if (body === '' || onSubmit === undefined) return
253
+ if (attachmentIds === undefined || attachmentIds.length === 0) onSubmit(body)
254
+ else onSubmit(body, undefined, attachmentIds)
255
+ setDraft('')
256
+ }, [draft, onSubmit, setDraft])
257
+
258
+ const isReplyingTo = useCallback(
259
+ (commentId: string) => replyingTo === commentId,
260
+ [replyingTo],
261
+ )
262
+
263
+ const startReply = useCallback((commentId: string) => {
264
+ setReplyingTo(commentId)
265
+ // A reply draft belongs to the comment it answers. Switching targets
266
+ // clears it rather than silently re-aiming half a sentence at someone else.
267
+ setReplyDraft('')
268
+ }, [])
269
+
270
+ const cancelReply = useCallback(() => {
271
+ setReplyingTo(null)
272
+ setReplyDraft('')
273
+ }, [])
274
+
275
+ const canSubmitReply =
276
+ replyingTo !== null && replyDraft.trim() !== '' && onSubmit !== undefined
277
+
278
+ const submitReply = useCallback((attachmentIds?: readonly string[]) => {
279
+ const body = replyDraft.trim()
280
+ if (body === '' || replyingTo === null || onSubmit === undefined) return
281
+ if (attachmentIds === undefined || attachmentIds.length === 0) onSubmit(body, replyingTo)
282
+ else onSubmit(body, replyingTo, attachmentIds)
283
+ setReplyDraft('')
284
+ setReplyingTo(null)
285
+ }, [replyDraft, replyingTo, onSubmit])
286
+
287
+ return {
288
+ now,
289
+ roots,
290
+ totalCount,
291
+ maxDepth,
292
+ repliesVisible,
293
+ engagement,
294
+ sort,
295
+ setSort,
296
+ isCollapsed,
297
+ setCollapsed,
298
+ toggleCollapse,
299
+ isRepliesExpanded,
300
+ expandReplies,
301
+ visibleReplies,
302
+ canReply,
303
+ draft,
304
+ setDraft,
305
+ canSubmit,
306
+ submit,
307
+ replyingTo,
308
+ isReplyingTo,
309
+ startReply,
310
+ cancelReply,
311
+ replyDraft,
312
+ setReplyDraft,
313
+ canSubmitReply,
314
+ submitReply,
315
+ }
316
+ }
package/src/tokens.css CHANGED
@@ -33,6 +33,14 @@
33
33
  * --destructive(-bg, -foreground)
34
34
  * • Folder: --folder-red/amber/emerald/teal/blue/violet/pink/
35
35
  * slate/ink (categorical palette for folder accents)
36
+ * • Series: --series-1…8 (categorical CHART palette; ADR-146 D2.
37
+ * theme-INDEPENDENT, and the SLOT ORDER IS LOAD-BEARING
38
+ * — see the block near the end of this file before
39
+ * "tidying" it)
40
+ * • Chart: --chart-grid, --chart-axis, --chart-band (chart
41
+ * structure — gridlines, the darker baseline rule, and
42
+ * the hover band; ADR-146 D3. Theme-tuned, and all
43
+ * three ALREADY CARRY ALPHA — never add a second one)
36
44
  * • Spacing: --space-1…12 (4 / 8 / 12 / 16 / 20 / 24 / 32 / 40 / 48)
37
45
  * • Radius: --radius-xs/sm/md/lg/xl/2xl/full, --radius (legacy)
38
46
  * • Motion: --ease-out, --ease-spring, --ease-in-out,
@@ -188,6 +196,34 @@
188
196
 
189
197
  --border: 255 255 255 / 0.08;
190
198
  --border-strong: 255 255 255 / 0.14;
199
+
200
+ /* ============================================================
201
+ CHART STRUCTURE — PUBLIC, theme-tuned (ADR-146 D3).
202
+ The greys a chart needs that are NOT a card border, plus the
203
+ hover-band fill. This is the "structure is required, not banned"
204
+ layer of the data-display recipe (CLAUDE.md §2): gridlines and a
205
+ baseline are what make a plot legible, and deleting them is the
206
+ flat/floating defect, not a cleanup.
207
+
208
+ --chart-axis IS DELIBERATELY DARKER THAN --chart-grid. The
209
+ baseline rule must outrank the gridlines. Every chart in the
210
+ Metoric kit draws it that way (#111111 baseline vs #dfe1e6 grid)
211
+ and it is the single detail that does most for chart legibility.
212
+ It lives as its own token precisely so it cannot drift back to
213
+ "same grey as the grid".
214
+
215
+ ALL THREE ALREADY CARRY AN ALPHA — they composite onto whichever
216
+ panel they sit on, so the panel stays opaque and the structure
217
+ stays translucent. Consume as `rgb(var(--chart-grid))` and NEVER
218
+ as `rgb(var(--chart-grid) / 0.5)`: a second alpha expands to
219
+ `rgb(255 255 255 / 0.07 / 0.5)`, which is invalid CSS and is
220
+ SILENTLY DISCARDED. `__tests__/token-shape.test.ts` fails on
221
+ exactly that shape.
222
+ ============================================================ */
223
+ --chart-grid: 255 255 255 / 0.07;
224
+ --chart-axis: 255 255 255 / 0.22;
225
+ --chart-band: 255 255 255 / 0.05;
226
+
191
227
  /* Semantic — PUBLIC */
192
228
  --success: 16 185 129;
193
229
  --success-bg: 16 185 129 / 0.12;
@@ -527,6 +563,14 @@
527
563
 
528
564
  --border: 0 0 0 / 0.12;
529
565
  --border-strong: 0 0 0 / 0.20;
566
+
567
+ /* Chart structure — see the :root block for the reasoning. The same
568
+ two invariants hold in light: --chart-axis stays darker than
569
+ --chart-grid, and all three already carry their alpha. */
570
+ --chart-grid: 0 0 0 / 0.08;
571
+ --chart-axis: 0 0 0 / 0.24;
572
+ --chart-band: 0 0 0 / 0.04;
573
+
530
574
  /* Bumped from 0.16 → 0.22 — inputs on a white --bg-input (above) sitting
531
575
  * inside a Card (--bg-card = 255 / same as input) need a stronger border
532
576
  * to delineate. The 0.16 value worked when --bg-input was slate-tinted; on
@@ -813,3 +857,71 @@
813
857
  --media-on-scrim: 255 255 255 / 0.88;
814
858
  --media-overlay-btn: 255 255 255 / 0.14;
815
859
  }
860
+
861
+ /* ============================================================
862
+ CATEGORICAL SERIES PALETTE (theme-INDEPENDENT) — PUBLIC
863
+ ------------------------------------------------------------
864
+ The chart series palette (ADR-146 D2). Hues carried over from
865
+ `theme-v2.css`, which designed them at constant lightness ~0.62
866
+ and then never shipped a consumer.
867
+
868
+ THEME-INDEPENDENT ON PURPOSE, and verified rather than assumed:
869
+ lightness 0.62 sits inside BOTH the light band (0.43-0.77) and the
870
+ dark band (0.48-0.67), and all eight clear 3:1 against #ffffff,
871
+ against --surface-frame (#f7f7f9) and against dark --surface-card
872
+ (#1b1b1e). Same precedent as --hierarchy-* and --platform-* above:
873
+ one block, no light/dark override.
874
+
875
+ BARE TRIPLES. The call site owns the alpha — a line is
876
+ `rgb(var(--series-1))` and the area under it is
877
+ `rgb(var(--series-1) / 0.12)`. A baked-in alpha would expand to
878
+ `rgb(2 137 234 / 0.3 / 0.12)`, which is invalid and silently
879
+ dropped, and it would make the token un-shareable between the two
880
+ uses.
881
+
882
+ ------------------------------------------------------------
883
+ THE SLOT ORDER IS LOAD-BEARING. DO NOT "TIDY" IT.
884
+ ------------------------------------------------------------
885
+ The order was chosen by measurement (the dataviz skill's
886
+ validate_palette.js, adjacent-pair list, both modes), not by eye.
887
+ The order declared in theme-v2.css FAILS — teal against pink is
888
+ ΔE 1.2 for a deuteranope. This order passes all five checks in
889
+ both themes: worst adjacent pair ΔE 13.2 deutan / 21.0 normal,
890
+ identical light and dark. Re-ordering the slots breaks that, and
891
+ nothing in the build will tell you.
892
+
893
+ Three placements are deliberate:
894
+ • BLUE IS SLOT 1, not red. Slot 1 is the single-series default,
895
+ so a red slot 1 would paint every one-series chart in the
896
+ brand accent and destroy accent scarcity. Blue carries no
897
+ product meaning.
898
+ • RED IS SLOT 8. Red is reserved for --accent and
899
+ --destructive. It only enters a chart at eight series, where
900
+ the reader has bigger problems. It stays in the palette so
901
+ the eight-slot contract holds rather than being deleted.
902
+ • GREEN (slot 2) AND RED (slot 8) ARE FAR APART, so a
903
+ two-series chart never accidentally reads as pass/fail.
904
+
905
+ THE SCATTER / ALL-PAIRS CAP. Forms where any two marks can touch
906
+ — scatter, bubble, choropleth, small multiples — take the harder
907
+ all-pairs test, which no ordering of these eight can pass. Those
908
+ forms use ONLY slots 1, 6 and 7 (blue / amber / teal: verified
909
+ all-pairs in both modes, worst ΔE 14.8, all five checks PASS) and
910
+ CAP AT THREE SERIES. A fourth folds into "Other" or facets into
911
+ small multiples. Never generate a ninth hue.
912
+
913
+ Resolution is a frozen literal lookup in the primitive, never a
914
+ constructed name (ADR-146 D8): `--series-${i}` that misses yields
915
+ an undefined property, which makes rgb() invalid, which the
916
+ browser discards in silence with every gate still green.
917
+ ============================================================ */
918
+ :root {
919
+ --series-1: 2 137 234; /* blue */
920
+ --series-2: 9 160 93; /* green */
921
+ --series-3: 153 100 229; /* violet */
922
+ --series-4: 113 149 3; /* olive */
923
+ --series-5: 212 73 150; /* pink */
924
+ --series-6: 186 116 4; /* amber */
925
+ --series-7: 14 154 148; /* teal */
926
+ --series-8: 226 74 62; /* red */
927
+ }