@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,327 @@
1
+ /**
2
+ * StatStrip — one card divided into equal columns by hairline borders.
3
+ *
4
+ * Promoted in ADR-146 D5 (stat layer); it is the Metoric kit's `Full Stats`
5
+ * anatomy (spec §7).
6
+ *
7
+ * CONSUMERS — measured 2026-09-06 (real `from '@lovett/ui'` imports under
8
+ * `apps/workspace/src`; the design-system gallery is a demo harness and does
9
+ * NOT count toward the ADR-008 D3 gate).
10
+ * TODAY (0 — gate NOT MET):
11
+ * • none. `lenses/budget-flighting/components/GlobalHeader.tsx` was
12
+ * migrated onto this primitive during ADR-146 and then REVERTED at the
13
+ * phase audit: measured in the gallery, this strip renders 116px tall in
14
+ * its most compact form (230px with deltas), against the ~29px inline
15
+ * chip row it replaced — and that row sits in a `flex items-center`
16
+ * header beside a ~28px select. A dashboard header strip (the Metoric
17
+ * "Full Stats" anatomy: label ABOVE value, 24px figure, --space-4
18
+ * padding) is simply a different object from a compact toolbar chip
19
+ * strip, and forcing one into the other was the wrong fit rather than a
20
+ * tuning problem. See the ADR-146 completion report, DEV-8.
21
+ * PLANNED second consumer: `lenses/sem-spec/output/output-stats-bar.tsx`,
22
+ * which hand-rolls this exact anatomy today ("one bordered card ... five
23
+ * hairline-divided stat cells", its own docstring). Not migrated. Tracked
24
+ * in `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
25
+ *
26
+ * WHY THIS IS NOT A GRID OF `StatCard`s
27
+ * -------------------------------------
28
+ * A dashboard header wants ONE object, not N. `GlobalHeader.tsx:32` forked a
29
+ * local strip for exactly this reason — its own comment says "a stat group,
30
+ * not four floating bordered pills (which read as form fields)" — and that
31
+ * fork is the evidence this shape is a primitive. So: one border, one radius,
32
+ * one surface, **no per-cell shadow and no gaps between cells**. The kit is
33
+ * explicit that `Full Stats` carries no tray either (ADR-146 D1 — a tray
34
+ * appears only when a widget holds more than one internal panel).
35
+ *
36
+ * ANATOMY (spec §7, real measurements mapped onto our tokens)
37
+ * -----------------------------------------------------------
38
+ * card opaque `--surface-card`, `--radius-lg`, 1px `--border`, no shadow
39
+ * cell pad `--space-4`, gap `--space-2`
40
+ * label 12px SemiBold, `--text-tertiary`, + optional 16px info icon
41
+ * value 24px SemiBold, **line-height 1**, tabular-nums, `--foreground`
42
+ * (the kit sets a metric solid while everything else is 1.5 — that
43
+ * is what stops a KPI reading loose, and it is worth keeping)
44
+ * footer `DeltaChip` + 14px `--text-secondary` context
45
+ *
46
+ * The card is styled here rather than composed from `Card` for the same
47
+ * reason `ChartFrame` is: `Card` brings a resting shadow, `h-full`, and the
48
+ * tray auto-wrap, none of which a flat divided strip wants. Same tokens,
49
+ * same surface — this is not a forked look, it is the same panel without the
50
+ * lift the kit explicitly does not draw here.
51
+ *
52
+ * THE DIVIDERS ACROSS A WRAP — the hard part of this component
53
+ * ------------------------------------------------------------
54
+ * Cells must be separated by a 1px rule with **none on the outside**: a
55
+ * vertical rule between cells in a row, a horizontal rule between wrapped
56
+ * rows, and nothing on the first row's top or the first column's start edge.
57
+ * No CSS selector can express "first item of each wrapped row", so the naive
58
+ * `:not(:last-child)` approach draws a stray vertical rule at the start of
59
+ * every row after the first.
60
+ *
61
+ * The generic answer, and the one used here: give EVERY cell a
62
+ * `border-inline-start` and a `border-block-start`, then shift the whole row
63
+ * container back by exactly one hairline (`margin-block-start:-1px`,
64
+ * `margin-inline-start:-1px`) inside an `overflow:hidden` card. The outermost
65
+ * borders land outside the padding box and are clipped; every interior one
66
+ * survives. It is correct for any cell count, at any wrap point, and it is
67
+ * why a single cell needs no special case — both of its borders are clipped,
68
+ * so it renders with no dividers at all, for free.
69
+ *
70
+ * Logical properties (`inline-start` / `block-start`) rather than left/top, so
71
+ * the strip is correct under RTL.
72
+ *
73
+ * RESPONSIVE — intrinsic, not device presets
74
+ * -------------------------------------------
75
+ * The row is `flex-wrap` with every cell at `flex: 1 1 <minCellWidth>`, so it
76
+ * holds its structure until the content stops fitting and then breaks — 4-up
77
+ * to 3-up to 2-up to 1-up — against **the strip's own width**, never the
78
+ * viewport. Two properties of flex earn it over a grid here:
79
+ *
80
+ * 1. **The last row fills.** `repeat(auto-fit, minmax(…, 1fr))` leaves an
81
+ * empty track when the count does not divide evenly (4 cells in 3
82
+ * columns = a visible hole beside the orphan). Flex grows the last row
83
+ * to full width instead, so the strip always reads as a finished object.
84
+ * 2. **Cells in a row stay equal.** Identical basis + identical grow means
85
+ * identical width, so a value that changes length cannot resize its own
86
+ * column (the D9 reserved-space rule holds structurally, without a
87
+ * per-cell `min-width` guess).
88
+ *
89
+ * Long localized labels wrap rather than truncate, and never widen a cell:
90
+ * every cell is `min-width: 0` with `overflow-wrap: anywhere`.
91
+ *
92
+ * The footer row (delta + context) is **reserved for every cell whenever any
93
+ * cell has one**, at a fixed `--space-6` min-height, so a cell WITHOUT a delta
94
+ * does not sit shorter than its neighbours (D9).
95
+ *
96
+ * That derivation cannot cover the case it most needs to: while the deltas are
97
+ * still in flight NO cell has one, so nothing is reserved, and the slot appears
98
+ * — growing the strip and shifting the page — the moment the fetch lands. The
99
+ * data cannot reserve space for itself before it exists. So `reserveFooter`
100
+ * overrides the derivation: a caller that KNOWS deltas are coming passes
101
+ * `reserveFooter` and the slot is there from the first paint.
102
+ *
103
+ * Token discipline: every colour is `rgb(var(--token))`; every padding, gap
104
+ * and radius is a token. The only bare number is the hairline (1px — the
105
+ * border width it cancels) and `minCellWidth`, which is a wrap threshold, not
106
+ * spacing. No motion: nothing here is interactive, and a stat that animates
107
+ * on hover advertises an interaction it does not have.
108
+ */
109
+
110
+ import { Info } from 'lucide-react'
111
+ import type { ReactNode } from 'react'
112
+
113
+ import { cn } from './lib/utils'
114
+ import { DeltaChip, type DeltaChipProps } from './delta-chip'
115
+
116
+ /**
117
+ * The divider width, and the offset that cancels the outermost ones.
118
+ * Not spacing — this is the border's own geometry, so it does not come off
119
+ * the `--space-*` scale. Kept as one constant so the two can never drift.
120
+ */
121
+ const HAIRLINE = '1px'
122
+ const HAIRLINE_OFFSET = '-1px'
123
+
124
+ /** Default wrap threshold: the narrowest a cell may get before the row breaks. */
125
+ const DEFAULT_MIN_CELL_WIDTH = 180
126
+
127
+ /**
128
+ * Default `ch` slot reserved for every value, matching `StatCard`'s `lg`
129
+ * reserve — "1,234,567" fits in 9. Strip-level rather than per-cell, because
130
+ * a strip's whole job is to read as one object with one rhythm.
131
+ */
132
+ const DEFAULT_VALUE_CHARS = 9
133
+
134
+ export interface StatStripCell {
135
+ /**
136
+ * Stable identity key — also the React key. Use the stat's own id, never
137
+ * the array index: a strip whose cells are filtered must not re-key.
138
+ */
139
+ key: string
140
+ /** The stat name. 12px semibold, muted. Wraps; never truncated. */
141
+ label: string
142
+ /**
143
+ * The headline value. Pre-format through `@lovett/ui/format` — the strip
144
+ * only sets it 24px semibold, solid, in `tabular-nums`.
145
+ */
146
+ value: ReactNode
147
+ /**
148
+ * Optional hint. Renders the 16px info icon beside the label, carrying this
149
+ * text as both its accessible name and its hover title.
150
+ */
151
+ info?: string
152
+ /**
153
+ * Optional change indicator. Props are handed straight to `DeltaChip` —
154
+ * the strip never re-implements the arrow/tone ternary that ADR-146 D5
155
+ * exists to delete.
156
+ */
157
+ delta?: DeltaChipProps
158
+ /**
159
+ * Optional 14px context beside the delta ("vs last month"). Wears
160
+ * `--text-secondary`; it is prose, not a number.
161
+ */
162
+ context?: ReactNode
163
+ }
164
+
165
+ export interface StatStripProps {
166
+ /** The stats, in reading order. Renders nothing when empty. */
167
+ cells: readonly StatStripCell[]
168
+ /**
169
+ * Narrowest a cell may get before the row wraps, in px. This is a layout
170
+ * threshold measured against the strip's own width — raise it for cells
171
+ * with long values, lower it for a dense strip of short counts.
172
+ */
173
+ minCellWidth?: number
174
+ /**
175
+ * Character slot reserved for every value so a ticking number cannot resize
176
+ * anything (D9). Sized for the largest plausible value; a longer one still
177
+ * grows past it rather than clipping.
178
+ */
179
+ valueChars?: number
180
+ /**
181
+ * Force the delta/context footer slot on (or off) instead of deriving it
182
+ * from the data. Pass `true` when the deltas arrive from a later fetch than
183
+ * the values: the derived default reserves nothing while every `delta` is
184
+ * still `undefined`, so the slot would pop in and grow the strip exactly
185
+ * when the numbers land (D9). Defaults to "reserve it if any cell has one".
186
+ */
187
+ reserveFooter?: boolean
188
+ /**
189
+ * Accessible name for the group, e.g. "Plan totals". Recommended whenever
190
+ * more than one strip is on a page.
191
+ */
192
+ ariaLabel?: string
193
+ /** Optional className on the outer card. */
194
+ className?: string
195
+ }
196
+
197
+ export function StatStrip({
198
+ cells,
199
+ minCellWidth = DEFAULT_MIN_CELL_WIDTH,
200
+ valueChars = DEFAULT_VALUE_CHARS,
201
+ reserveFooter,
202
+ ariaLabel,
203
+ className,
204
+ }: StatStripProps) {
205
+ // An empty strip is an empty bordered box — worse than nothing. A caller
206
+ // with a genuinely empty data set shows its own empty state.
207
+ if (cells.length === 0) return null
208
+
209
+ // Reserve the footer for every cell as soon as ANY cell owns one, so the
210
+ // strip's height is decided by its shape and not by which cell has a delta.
211
+ // An explicit `reserveFooter` wins, because the derivation is blind to the
212
+ // one case that matters: deltas that have not arrived yet.
213
+ const showFooter =
214
+ reserveFooter ??
215
+ cells.some(
216
+ (cell) => cell.delta !== undefined || cell.context !== undefined,
217
+ )
218
+
219
+ return (
220
+ <div
221
+ className={cn('overflow-hidden', className)}
222
+ style={{
223
+ background: 'rgb(var(--surface-card))',
224
+ border: `${HAIRLINE} solid rgb(var(--border))`,
225
+ borderRadius: 'var(--radius-lg)',
226
+ }}
227
+ >
228
+ <dl
229
+ aria-label={ariaLabel}
230
+ className="flex flex-wrap"
231
+ style={{
232
+ // Pulled back by one hairline in both axes so the first row's top
233
+ // borders and the first column's start borders fall outside the
234
+ // padding box and are clipped by `overflow:hidden` above. Every
235
+ // interior divider survives, at any wrap point. See the header.
236
+ margin: 0,
237
+ marginBlockStart: HAIRLINE_OFFSET,
238
+ marginInlineStart: HAIRLINE_OFFSET,
239
+ }}
240
+ >
241
+ {cells.map((cell) => (
242
+ <div
243
+ key={cell.key}
244
+ className="flex min-w-0 flex-col"
245
+ style={{
246
+ flex: `1 1 ${minCellWidth}px`,
247
+ padding: 'var(--space-4)',
248
+ gap: 'var(--space-2)',
249
+ borderBlockStart: `${HAIRLINE} solid rgb(var(--border))`,
250
+ borderInlineStart: `${HAIRLINE} solid rgb(var(--border))`,
251
+ }}
252
+ >
253
+ <dt
254
+ className="flex items-start text-[12px] font-semibold leading-[1.5]"
255
+ style={{
256
+ gap: 'var(--space-1)',
257
+ color: 'rgb(var(--text-tertiary))',
258
+ }}
259
+ >
260
+ <span className="min-w-0 [overflow-wrap:anywhere]">
261
+ {cell.label}
262
+ </span>
263
+ {cell.info !== undefined && (
264
+ // `sr-only` text rather than `role="img" aria-label`, which
265
+ // is the same pattern `DeltaChip` uses for its direction word.
266
+ // An aria-label names the icon and `title` then DESCRIBES it,
267
+ // so an accname-correct reader announces the same sentence
268
+ // twice. Here the hint is read once, as part of the <dt>, and
269
+ // `title` stays for the sighted hover.
270
+ <span
271
+ title={cell.info}
272
+ className="inline-flex shrink-0"
273
+ style={{ color: 'rgb(var(--text-muted))' }}
274
+ >
275
+ <Info size={16} aria-hidden="true" />
276
+ <span className="sr-only">{cell.info}</span>
277
+ </span>
278
+ )}
279
+ </dt>
280
+
281
+ <dd
282
+ className="text-[24px] font-semibold leading-none tabular-nums [overflow-wrap:anywhere]"
283
+ style={{
284
+ margin: 0,
285
+ color: 'rgb(var(--foreground))',
286
+ minWidth: `${valueChars}ch`,
287
+ }}
288
+ >
289
+ {cell.value}
290
+ </dd>
291
+
292
+ {showFooter && (
293
+ <dd
294
+ // A reserved-but-empty slot is a blank <dd>, which AT reads as
295
+ // an empty description of the stat above it. It is spacing, so
296
+ // it is hidden rather than announced.
297
+ aria-hidden={
298
+ cell.delta === undefined && cell.context === undefined
299
+ ? true
300
+ : undefined
301
+ }
302
+ className="flex flex-wrap items-center"
303
+ style={{
304
+ margin: 0,
305
+ gap: 'var(--space-2)',
306
+ // Reserved, not conditional: the slot exists in every cell
307
+ // so a late-arriving delta cannot grow the strip (D9).
308
+ minHeight: 'var(--space-6)',
309
+ }}
310
+ >
311
+ {cell.delta !== undefined && <DeltaChip {...cell.delta} />}
312
+ {cell.context !== undefined && (
313
+ <span
314
+ className="text-[14px] leading-[1.5] [overflow-wrap:anywhere]"
315
+ style={{ color: 'rgb(var(--text-secondary))' }}
316
+ >
317
+ {cell.context}
318
+ </span>
319
+ )}
320
+ </dd>
321
+ )}
322
+ </div>
323
+ ))}
324
+ </dl>
325
+ </div>
326
+ )
327
+ }
package/src/status.tsx ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The status vocabulary — `StatusRing` (degree of completion) and
3
+ * `StatusGlyph` (discrete state).
4
+ *
5
+ * `docs/design-briefs/tasks-board-header-primitives.md` §2. The source app
6
+ * (continuity-control) hand-wrote six inline-SVG glyphs that take no props at
7
+ * all, and filled them from a HARDCODED constant per status — a control that
8
+ * looks like a meter and measures nothing, rendered next to a real `62%` from
9
+ * a different source. Two adjacent sources of truth, and neither one is the
10
+ * ledger's.
11
+ *
12
+ * So this file draws the line the source never did:
13
+ *
14
+ * - **A real number gets a ring.** `StatusRing` takes a percent and renders
15
+ * that percent. It is fed by `ledgerProgress()` (progress-ledger.tsx) —
16
+ * the SAME function that computes the number the ledger prints as text, so
17
+ * a ring and its ledger cannot disagree.
18
+ * - **A discrete state gets a lucide glyph**, sized and coloured by props
19
+ * like every other icon in the system (CLAUDE.md §6). Five of the six
20
+ * shapes the source hand-rolled already ship in lucide; the partial ring
21
+ * was the only real gap, and that is exactly what `StatusRing` is.
22
+ *
23
+ * Colour comes from the `--folder-*` hue via the tint ladder (`hue.ts`), and
24
+ * NOTHING here is legible by colour alone: a glyph carries its state in its
25
+ * accessible name (or in an adjacent visible label), and a ring carries its
26
+ * percent in its accessible name (or is marked decorative when the number
27
+ * already sits beside it as text). Never both — a ring announced next to a
28
+ * printed percentage says the number twice.
29
+ */
30
+
31
+ import type { CSSProperties } from 'react'
32
+ import {
33
+ Circle,
34
+ CircleCheck,
35
+ CircleDashed,
36
+ CircleDotDashed,
37
+ CircleSlash,
38
+ CircleX,
39
+ type LucideIcon,
40
+ } from 'lucide-react'
41
+ import { cn } from './lib/utils'
42
+ import CompletionRing from './completion-ring'
43
+ import { hueClass, type BoardHueKey } from './hue'
44
+
45
+ /**
46
+ * The six discrete states.
47
+ *
48
+ * `blocked` has no signal behind it in the Tasks board yet — an item carries
49
+ * no blocked flag — so it is vocabulary, not a rendered state, until one
50
+ * exists. It is declared here rather than added later because the map is what
51
+ * makes the set consistent across a column, a rail row and a card.
52
+ */
53
+ export type BoardStatusKey =
54
+ | 'empty'
55
+ | 'todo'
56
+ | 'in-progress'
57
+ | 'blocked'
58
+ | 'done'
59
+ | 'cancelled'
60
+
61
+ export const BOARD_STATUS_KEYS: readonly BoardStatusKey[] = [
62
+ 'empty',
63
+ 'todo',
64
+ 'in-progress',
65
+ 'blocked',
66
+ 'done',
67
+ 'cancelled',
68
+ ]
69
+
70
+ /**
71
+ * Lucide, never a hand-rolled `<svg>`. The shapes match the source's own queue
72
+ * view, which is the better half of its codebase — its comment says the icon
73
+ * must carry the meaning so state never reads by colour alone, and this map is
74
+ * how that stays true in one place instead of six call sites.
75
+ */
76
+ export const STATUS_GLYPHS: Readonly<Record<BoardStatusKey, LucideIcon>> = {
77
+ empty: CircleDashed,
78
+ todo: Circle,
79
+ 'in-progress': CircleDotDashed,
80
+ blocked: CircleSlash,
81
+ done: CircleCheck,
82
+ cancelled: CircleX,
83
+ }
84
+
85
+ /** Operator language, sentence case (interface-standards §7). No schema words. */
86
+ export const STATUS_LABELS: Readonly<Record<BoardStatusKey, string>> = {
87
+ empty: 'Nothing here yet',
88
+ todo: 'Not started',
89
+ 'in-progress': 'In progress',
90
+ blocked: 'Blocked',
91
+ done: 'Done',
92
+ cancelled: 'Cancelled',
93
+ }
94
+
95
+ export interface StatusGlyphProps {
96
+ status: BoardStatusKey
97
+ /** Optical size in px. Default 14 — the board header's marker size. */
98
+ size?: number
99
+ /**
100
+ * Default 2, matching a 600-weight label (interface-standards §5). Pass 1.5
101
+ * beside 400-weight meta text.
102
+ */
103
+ strokeWidth?: number
104
+ /** Overrides the accessible name. Defaults to the state's own label. */
105
+ label?: string
106
+ /**
107
+ * True when the state is ALREADY spelled out in adjacent visible text — the
108
+ * glyph then adds nothing to the accessible name and only repeats it.
109
+ */
110
+ decorative?: boolean
111
+ className?: string
112
+ }
113
+
114
+ export function StatusGlyph({
115
+ status,
116
+ size = 14,
117
+ strokeWidth = 2,
118
+ label,
119
+ decorative = false,
120
+ className,
121
+ }: StatusGlyphProps) {
122
+ const Glyph = STATUS_GLYPHS[status]
123
+ return (
124
+ <Glyph
125
+ size={size}
126
+ strokeWidth={strokeWidth}
127
+ className={cn('shrink-0', className)}
128
+ // The INK rung, not the raw hue: `--h` on a frame surface measures under
129
+ // 3:1 in light mode for half the ladder. `--tint-ink` mixes toward
130
+ // --foreground, so it is readable in BOTH themes. Falls back to a
131
+ // neutral when no `.hue` ancestor has armed the ladder.
132
+ style={{ color: 'var(--tint-ink, rgb(var(--text-tertiary)))' }}
133
+ {...(decorative
134
+ ? { 'aria-hidden': true }
135
+ : { role: 'img', 'aria-label': label ?? STATUS_LABELS[status] })}
136
+ />
137
+ )
138
+ }
139
+
140
+ export interface StatusRingProps {
141
+ /** 0–100. Values outside the range are clamped, never wrapped. */
142
+ percent: number
143
+ /** Outer dimension in px. Default 14. */
144
+ size?: number
145
+ /** Ring stroke in px. Default 2. */
146
+ strokeWidth?: number
147
+ /**
148
+ * Arms the tint ladder locally. Omit when an ancestor already carries
149
+ * `hueClass(...)` — the board column and the card both do.
150
+ */
151
+ hue?: BoardHueKey
152
+ /** Overrides the accessible name. Defaults to `{n}% complete`. */
153
+ label?: string
154
+ /**
155
+ * True when a text percentage sits beside it. The ring is then `aria-hidden`
156
+ * so the number is announced once, not twice.
157
+ */
158
+ decorative?: boolean
159
+ className?: string
160
+ style?: CSSProperties
161
+ }
162
+
163
+ /**
164
+ * The arc-stroke ring (header-primitives §2):
165
+ *
166
+ * circumference = 2πr
167
+ * strokeDashoffset = circumference · (1 − clamp(percent, 0, 100) / 100)
168
+ * transform="rotate(-90 c c)" strokeLinecap="round"
169
+ *
170
+ * That geometry already exists once in this package, in `CompletionRing`, so
171
+ * this composes it rather than pasting a second copy of the maths — a second
172
+ * copy is precisely the defect the brief indicts. What `StatusRing` adds is
173
+ * the board's contract on top of it: the hue ladder instead of the accent, a
174
+ * `--border` track, no centre label (the ledger prints the number), and an
175
+ * explicit announced-or-decorative choice.
176
+ */
177
+ export function StatusRing({
178
+ percent,
179
+ size = 14,
180
+ strokeWidth = 2,
181
+ hue,
182
+ label,
183
+ decorative = false,
184
+ className,
185
+ style,
186
+ }: StatusRingProps) {
187
+ const clamped = Math.max(0, Math.min(100, percent))
188
+ const name = label ?? `${Math.round(clamped)}% complete`
189
+
190
+ return (
191
+ <span
192
+ className={cn(
193
+ 'ds-status-ring inline-flex shrink-0',
194
+ hue ? hueClass(hue) : undefined,
195
+ className,
196
+ )}
197
+ style={style}
198
+ {...(decorative ? { 'aria-hidden': true } : { role: 'img', 'aria-label': name })}
199
+ >
200
+ <CompletionRing
201
+ percent={clamped}
202
+ size={size}
203
+ strokeWidth={strokeWidth}
204
+ label={false}
205
+ // Grey structure, per the data-display recipe — the track is the
206
+ // unfilled remainder, not a surface.
207
+ trackColor="rgb(var(--border))"
208
+ // The hue ladder's raw hue. The fallback is the ladder's own default,
209
+ // so a ring outside a `.hue` ancestor renders slate rather than
210
+ // nothing.
211
+ fillColor="var(--h, rgb(var(--folder-slate)))"
212
+ />
213
+ </span>
214
+ )
215
+ }