@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,435 @@
1
+ /**
2
+ * GaugeRing — a single-arc radial gauge: track, value arc, centre readout.
3
+ *
4
+ * Promoted in ADR-146 D5 (chart layer). This is `CompletionRing`
5
+ * generalised, and it absorbs the hand-rolled 88px fit-score ring at
6
+ * `lenses/discovery/geo/analysis-panel.tsx:176-198` — the duplicate ADR-146's
7
+ * Context calls out by name, and the one carrying an
8
+ * `eslint-disable no-restricted-syntax` for the charts exception. Moving it
9
+ * here is what lets that disable come out (ADR-146 D4, follow-up
10
+ * `geo-chart-primitives.md`).
11
+ *
12
+ * CONSUMERS — measured 2026-09-06 (real `from '@lovett/ui'` imports under
13
+ * `apps/workspace/src`; the design-system gallery is a demo harness and does
14
+ * NOT count toward the ADR-008 D3 gate).
15
+ * TODAY (0 — gate NOT MET). The absorption described above has NOT
16
+ * happened yet: `lenses/discovery/geo/analysis-panel.tsx:177` still draws
17
+ * its own 88px ring and still carries the `eslint-disable`.
18
+ * PLANNED: that panel (banded fit score, 88px, 270° gauge) and the
19
+ * brand-profile / dashboard completion rail (plain 0-100 percentage, small
20
+ * full circle, `CompletionRing` today) — the two shapes this generalises.
21
+ * Tracked in `docs/follow-ups/_pending/adr-146-consumer-gate.md` and
22
+ * `docs/follow-ups/_pending/geo-chart-primitives.md`.
23
+ *
24
+ * <GaugeRing value={80} unit="%" />
25
+ * <GaugeRing value={pct} size={38} strokeWidth={3} />
26
+ * <GaugeRing value={pct} showLabel={false} />
27
+ * <GaugeRing value={pct} label={<Check className="h-3 w-3" />} />
28
+ * <GaugeRing value={83} size={88} strokeWidth={7}
29
+ * startAngle={225} sweep={270} bands={FIT_BANDS} />
30
+ *
31
+ * WHAT IT ADDS OVER `CompletionRing`
32
+ * ----------------------------------
33
+ * • **Bands.** `bands` maps value ranges onto semantic tones, so a fit
34
+ * score paints destructive / warning / success by where it lands. The
35
+ * matched band's `label` renders as TEXT beside the hue — never the hue
36
+ * alone (ADR-146 D10.1) — and goes into the `aria-label`.
37
+ * • **A partial arc.** `startAngle` + `sweep` give the 270° gauge shape.
38
+ * `CompletionRing` could only draw a full turn.
39
+ * • **A structural track**, `--border-strong`, which reads against every
40
+ * surface a gauge can land on — card, frame AND the recessed inset well
41
+ * — rather than a translucent overlay tint. See `trackColor` for the
42
+ * measured numbers and for why it is NOT `--muted`.
43
+ * • **A structured centre readout** — value, optional `unit` suffix,
44
+ * optional `caption` beneath — instead of one opaque `label` node.
45
+ *
46
+ * `CompletionRing` is NOT deleted here. Migration is ADR-146 D13.
47
+ *
48
+ * WHY THE ARITHMETIC IS IMPORTED, NOT REWRITTEN
49
+ * ---------------------------------------------
50
+ * The circumference / dash accumulator was written three times before
51
+ * ADR-146 D6 (`completion-ring.tsx:58-64`, `shell-charts.tsx:63-91`, and the
52
+ * geo fork), each with its own answer for a zero radius and an all-zero
53
+ * series. This file derives none of it: `arcGeometry`, `arcSegments` and
54
+ * `clampPct` come from `./lib/chart`, whose output contract guarantees no
55
+ * `NaN` ever reaches an SVG attribute. A `NaN` in `stroke-dasharray` does not
56
+ * look broken — it drops the element silently, with every gate green.
57
+ *
58
+ * SVG is the right tool for ring geometry. CLAUDE.md §6's "no inline SVG"
59
+ * is about ICONS (use Lucide); it explicitly sanctions chart and ring
60
+ * geometry inside `packages/ui/`.
61
+ *
62
+ * Token discipline: every colour is a whole `rgb(var(--token))` string from a
63
+ * frozen literal map — no colour literal, and no custom-property name is ever
64
+ * composed at runtime (ADR-146 D8: a constructed name that misses yields
65
+ * invalid CSS the browser silently discards). The track is `--border-strong`,
66
+ * chart STRUCTURE inside a panel, which the data-display recipe requires — and
67
+ * pointedly NOT `--muted`, which aliases the recessed `--surface-inset` and
68
+ * therefore vanished at 1.00:1 whenever a gauge sat in a well (see
69
+ * `trackColor`). Band text wears the theme-tuned `*-ink` companions, because
70
+ * `--success` / `--warning` / `--accent` are sized as fills and are too light
71
+ * to read as ink on a light card. Size and stroke are numbers, applied
72
+ * through inline `style` and SVG attributes: a Tailwind arbitrary value built
73
+ * from a template literal (`` h-[${n}px] ``) is statically unscannable and
74
+ * silently produces nothing.
75
+ */
76
+
77
+ import type { CSSProperties, ReactNode } from 'react'
78
+
79
+ import { arcGeometry, arcSegments, clampPct } from './lib/chart'
80
+ import { cn } from './lib/utils'
81
+
82
+ /** Semantic tones a gauge arc may take. Identity is never tone alone (D10.1). */
83
+ export type GaugeTone = 'accent' | 'neutral' | 'success' | 'warning' | 'destructive' | 'info'
84
+
85
+ /**
86
+ * The arc colour per tone. A frozen literal map, the same shape as
87
+ * `metric-card.tsx`'s `TONE_COLOR` — never `` `--${tone}` ``.
88
+ */
89
+ const TONE_ARC: Record<GaugeTone, string> = {
90
+ accent: 'rgb(var(--accent))',
91
+ neutral: 'rgb(var(--foreground))',
92
+ success: 'rgb(var(--success))',
93
+ warning: 'rgb(var(--warning))',
94
+ destructive: 'rgb(var(--destructive))',
95
+ info: 'rgb(var(--info))',
96
+ }
97
+
98
+ /**
99
+ * The band-label colour per tone — the `*-ink` companion, not the fill.
100
+ *
101
+ * `--accent` as text is 5.64:1 on a light card but only 3.05:1 on a dark one,
102
+ * and `--success` / `--warning` are sized to work as fills and icon strokes.
103
+ * The `-ink` tokens are the theme-tuned answer (CLAUDE.md §2, `tokens.css`
104
+ * "SEMANTIC INK"). `--info` has no `-ink` companion today and does not need
105
+ * one — it clears AA as text in both themes — so it maps to itself.
106
+ */
107
+ const TONE_INK: Record<GaugeTone, string> = {
108
+ accent: 'rgb(var(--accent-ink))',
109
+ neutral: 'rgb(var(--foreground))',
110
+ success: 'rgb(var(--success-ink))',
111
+ warning: 'rgb(var(--warning-ink))',
112
+ destructive: 'rgb(var(--destructive-ink))',
113
+ info: 'rgb(var(--info))',
114
+ }
115
+
116
+ /** One threshold band. Bands partition `0..max` by their lower bounds. */
117
+ export interface GaugeBand {
118
+ /**
119
+ * Inclusive lower bound, in the same units as `value`. The matched band is
120
+ * the last one whose `from` is at or below the value; a value below every
121
+ * bound takes the lowest band.
122
+ */
123
+ from: number
124
+ /** Tone the arc takes inside this band. */
125
+ tone: GaugeTone
126
+ /**
127
+ * The band's name — "Weak", "Fair", "Strong". REQUIRED, because a banded
128
+ * ring must expose its band as text, not only as a hue (ADR-146 D10.1).
129
+ * It renders under the value and goes into the `aria-label`.
130
+ */
131
+ label: string
132
+ /**
133
+ * Optional Lucide icon rendered before the band label — the third,
134
+ * redundant channel. Colour it with `currentColor`; the label row already
135
+ * carries the band's ink.
136
+ */
137
+ icon?: ReactNode
138
+ }
139
+
140
+ export interface GaugeRingProps {
141
+ /** The measured value. Clamped into `0..max` for the arc. */
142
+ value: number
143
+ /** Top of the scale. Default `100`. A `max` at or below zero draws empty. */
144
+ max?: number
145
+ /** Outer dimension in px. Default `30`. */
146
+ size?: number
147
+ /** Ring stroke width in px. Default scales with size (`size / 12`, min 2). */
148
+ strokeWidth?: number
149
+ /**
150
+ * Where the arc starts, in degrees clockwise from twelve o'clock.
151
+ * Default `0`.
152
+ *
153
+ * The arc runs CLOCKWISE from there, so the gap of a partial sweep sits
154
+ * *before* the start: a 270° gauge with its gap centred at the BOTTOM is
155
+ * `startAngle={225} sweep={270}` (arc 225° → 135°, gap 135° → 225°).
156
+ * `startAngle={135}` is the same gauge rotated a quarter turn, with its
157
+ * gap on the right — pinned by the rotation test, because the two are
158
+ * indistinguishable in a green build and obvious in a browser.
159
+ */
160
+ startAngle?: number
161
+ /** How far the arc sweeps, in degrees. Default `360` (a full ring). */
162
+ sweep?: number
163
+ /**
164
+ * Threshold bands. When present they choose the tone, and the matched
165
+ * band's `label` renders as text and enters the `aria-label`. Order does
166
+ * not matter — they are sorted by `from`.
167
+ */
168
+ bands?: readonly GaugeBand[]
169
+ /** Tone when there are no `bands`. Default `'accent'`. */
170
+ tone?: GaugeTone
171
+ /**
172
+ * Suffix printed immediately after the value, at a smaller size —
173
+ * `"%"`, `"pts"`. Not a caption; it sits on the value's baseline.
174
+ */
175
+ unit?: string
176
+ /**
177
+ * Small line beneath the value — `"/ 100"`, `"of target"`. Defaults to the
178
+ * matched band's label when `bands` are given. Pass `false` to suppress
179
+ * that default.
180
+ *
181
+ * **Passing a caption alongside `bands` REPLACES the band label**, which
182
+ * leaves the band carried by hue alone on screen (the `aria-label` still
183
+ * names it). That is the shape ADR-146 D10.1 forbids, so do it only when
184
+ * the band name is already rendered beside the gauge. When in doubt, let
185
+ * the band label stand and put "/ 100" in the surrounding copy.
186
+ */
187
+ caption?: ReactNode | false
188
+ /** Render the value as something other than a rounded integer. */
189
+ formatValue?: (value: number) => string
190
+ /**
191
+ * Replace the whole centre readout. A node wins over `unit` / `caption`;
192
+ * `false` / `null` hides the readout entirely.
193
+ */
194
+ label?: ReactNode | false
195
+ /** Convenience: `false` hides the default readout. Same as `label={false}`. */
196
+ showLabel?: boolean
197
+ /**
198
+ * Track colour. Default `rgb(var(--border-strong))`.
199
+ *
200
+ * NOT `--muted`. `--muted` aliases `--surface-inset`, which IS the recessed
201
+ * well surface, so a gauge dropped into a well painted its track in exactly
202
+ * the colour behind it: measured 242,242,244 on 242,242,244 in light and
203
+ * 45,45,48 on 45,45,48 in dark — **1.00:1, both themes**, i.e. a 0% gauge
204
+ * rendered as bare text with no ring. `--muted` is for small genuinely-muted
205
+ * FILLS (CLAUDE.md §2); a mark that must read against a surface which may
206
+ * itself be `--muted` needs a structural token, not a fill token.
207
+ *
208
+ * `--border-strong` is the structural choice, measured against every surface
209
+ * a gauge can land on (composited, WCAG ratio vs. its own ground):
210
+ *
211
+ * | | card | frame | inset |
212
+ * |---------------|--------|--------|--------|
213
+ * | light | 1.61:1 | 1.60:1 | 1.60:1 |
214
+ * | dark | 1.54:1 | 1.51:1 | 1.56:1 |
215
+ *
216
+ * `--border` was the other candidate and clears a thinner margin (1.23–1.32
217
+ * across the same six), and `--muted` bottoms out at 1.00. `--border-strong`
218
+ * is the only one of the three that never drops below 1.5.
219
+ *
220
+ * Pass a WHOLE `rgb(var(--token))` string — never a literal, never a
221
+ * composed name (ADR-146 D8).
222
+ */
223
+ trackColor?: string
224
+ /**
225
+ * Arc colour, overriding `tone` / `bands`. Same rule as `trackColor`:
226
+ * a whole token string. Exists so `CompletionRing`'s callers migrate
227
+ * without losing the escape hatch.
228
+ */
229
+ fillColor?: string
230
+ /**
231
+ * Screen-reader text. Defaults to the value, the scale, and — when banded
232
+ * — the band name, which is the contract ADR-146 D10.1 asks for.
233
+ */
234
+ ariaLabel?: string
235
+ /** Extra classes / inline style on the wrapper. */
236
+ className?: string
237
+ style?: CSSProperties
238
+ }
239
+
240
+ /** The band a value falls into: the last bound at or below it, else the lowest. */
241
+ function matchBand(
242
+ bands: readonly GaugeBand[],
243
+ value: number,
244
+ ): GaugeBand | undefined {
245
+ if (bands.length === 0) return undefined
246
+ const ordered = [...bands].sort((a, b) => a.from - b.from)
247
+ let matched = ordered[0]
248
+ for (const band of ordered) {
249
+ if (value >= band.from) matched = band
250
+ }
251
+ return matched
252
+ }
253
+
254
+ export function GaugeRing({
255
+ value,
256
+ max = 100,
257
+ size = 30,
258
+ strokeWidth,
259
+ startAngle = 0,
260
+ sweep = 360,
261
+ bands,
262
+ tone = 'accent',
263
+ unit,
264
+ caption,
265
+ formatValue,
266
+ label,
267
+ showLabel = true,
268
+ trackColor = 'rgb(var(--border-strong))',
269
+ fillColor,
270
+ ariaLabel,
271
+ className,
272
+ style,
273
+ }: GaugeRingProps) {
274
+ const safeMax = Number.isFinite(max) && max > 0 ? max : 0
275
+ const safeValue = Number.isFinite(value) ? value : 0
276
+ // `clampPct` absorbs NaN / Infinity / a zero denominator to 0 — the whole
277
+ // reason the ratio is routed through it rather than divided inline.
278
+ const percent = clampPct(safeMax > 0 ? (safeValue / safeMax) * 100 : 0)
279
+ const fill = percent / 100
280
+
281
+ // Geometry inputs are absorbed here, before anything reaches an SVG
282
+ // attribute. `width`, `height`, `viewBox` and `stroke-width` are ours to
283
+ // guard — `lib/chart` only guarantees the values it returns.
284
+ const safeSize = Number.isFinite(size) && size > 0 ? size : 0
285
+ const requested = strokeWidth ?? Math.max(2, Math.round(safeSize / 12))
286
+ const sw = Number.isFinite(requested) && requested > 0 ? requested : 0
287
+ const { radius } = arcGeometry(safeSize / 2, sw)
288
+ const centre = safeSize / 2
289
+ const spin = -90 + (Number.isFinite(startAngle) ? startAngle : 0)
290
+
291
+ // Fraction of a full turn the gauge occupies. A 270° gauge is 0.75.
292
+ const sweepFraction = clampPct((Number.isFinite(sweep) ? sweep : 360) / 3.6) / 100
293
+
294
+ // Both arcs come out of the shared accumulator rather than a local
295
+ // circumference expression: the second element is the unpainted remainder,
296
+ // so each `[0]` is the arc we want and both start at offset 0.
297
+ const [trackArc] = arcSegments([sweepFraction, 1 - sweepFraction], safeSize / 2, sw)
298
+ const painted = sweepFraction * fill
299
+ const [valueArc] = arcSegments([painted, 1 - painted], safeSize / 2, sw)
300
+
301
+ const band = bands ? matchBand(bands, safeValue) : undefined
302
+ const activeTone: GaugeTone = band?.tone ?? tone
303
+ const arcColor = fillColor ?? TONE_ARC[activeTone]
304
+
305
+ const valueText = formatValue ? formatValue(safeValue) : String(Math.round(safeValue))
306
+
307
+ // Reserve the readout's width for the largest plausible reading so the ring
308
+ // never resizes as the value ticks. `ch` is stable under tabular-nums.
309
+ const widestReading = (formatValue ? formatValue(safeMax) : String(Math.round(safeMax))).length
310
+ const readoutCh = Math.max(valueText.length, widestReading) + (unit ? unit.length : 0)
311
+
312
+ const valueSize = Math.max(9, Math.round(safeSize * 0.28))
313
+ const unitSize = Math.max(8, Math.round(valueSize * 0.55))
314
+ const captionSize = Math.max(8, Math.round(safeSize * 0.11))
315
+
316
+ const resolvedCaption = caption === undefined ? band?.label : caption
317
+ const showBandIcon = caption === undefined && band?.icon !== undefined
318
+
319
+ // A non-finite or non-positive `max` collapses to 0, and "10 of 0" is a
320
+ // worse accessible name than "10". Announce the value alone instead.
321
+ const spokenScale =
322
+ safeMax > 0
323
+ ? ` of ${formatValue ? formatValue(safeMax) : String(Math.round(safeMax))}${unit ?? ''}`
324
+ : ''
325
+ const spokenValue = `${valueText}${unit ?? ''}${spokenScale}`
326
+ const resolvedAriaLabel =
327
+ ariaLabel ?? (band ? `${spokenValue} — ${band.label}` : spokenValue)
328
+
329
+ // `label` (when provided) wins over `showLabel` — the same precedence
330
+ // CompletionRing established, so a migrating caller keeps its behaviour.
331
+ const overrideLabel = label !== undefined && label !== false && label !== null
332
+ const hasReadout = overrideLabel || (label === undefined && showLabel)
333
+
334
+ return (
335
+ <div
336
+ role="img"
337
+ aria-label={resolvedAriaLabel}
338
+ className={cn('relative shrink-0', className)}
339
+ style={{ width: safeSize, height: safeSize, ...style }}
340
+ >
341
+ <svg
342
+ width={safeSize}
343
+ height={safeSize}
344
+ viewBox={`0 0 ${safeSize} ${safeSize}`}
345
+ aria-hidden="true"
346
+ focusable="false"
347
+ >
348
+ <g transform={`rotate(${spin} ${centre} ${centre})`}>
349
+ <circle
350
+ cx={centre}
351
+ cy={centre}
352
+ r={radius}
353
+ fill="none"
354
+ stroke={trackColor}
355
+ strokeWidth={sw}
356
+ strokeLinecap={trackArc && trackArc.fraction > 0 && sweepFraction < 1 ? 'round' : 'butt'}
357
+ strokeDasharray={trackArc?.dasharray}
358
+ />
359
+ <circle
360
+ cx={centre}
361
+ cy={centre}
362
+ r={radius}
363
+ fill="none"
364
+ stroke={arcColor}
365
+ strokeWidth={sw}
366
+ // A round cap on a zero-length dash paints a stray dot, so an
367
+ // empty gauge butts instead. This is the "all-zero series" case
368
+ // ADR-146 names as a required test.
369
+ strokeLinecap={valueArc && valueArc.fraction > 0 ? 'round' : 'butt'}
370
+ strokeDasharray={valueArc?.dasharray}
371
+ // Data changes tween with --dur-base / --ease-out, never
372
+ // --ease-spring: overshoot on a measurement reads as imprecise
373
+ // (ADR-146 D9).
374
+ //
375
+ // `motion-reduce:transition-none` is load-bearing, not belt and
376
+ // braces. The duration and easing below are inline and therefore
377
+ // unconditional; `transition-property`'s INITIAL value is `all`,
378
+ // so under prefers-reduced-motion — where the motion-safe class
379
+ // does not apply — the element would inherit `transition: all
380
+ // var(--dur-base)` and tween the stroke colour, radius and
381
+ // opacity too. A reduce user would get MORE motion than everyone
382
+ // else. Declaring `transition-property: none` there is what
383
+ // actually makes the arc jump to its final state.
384
+ className="motion-safe:transition-[stroke-dasharray] motion-reduce:transition-none"
385
+ style={{
386
+ transitionDuration: 'var(--dur-base)',
387
+ transitionTimingFunction: 'var(--ease-out)',
388
+ }}
389
+ />
390
+ </g>
391
+ </svg>
392
+
393
+ {hasReadout && (
394
+ <div
395
+ className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center leading-none"
396
+ aria-hidden="true"
397
+ >
398
+ {overrideLabel ? (
399
+ label
400
+ ) : (
401
+ <>
402
+ <div
403
+ className="flex items-baseline justify-center font-bold tabular-nums leading-none"
404
+ style={{
405
+ color: 'rgb(var(--foreground))',
406
+ fontSize: valueSize,
407
+ minWidth: `${readoutCh}ch`,
408
+ }}
409
+ >
410
+ {valueText}
411
+ {unit && (
412
+ <span className="font-semibold" style={{ fontSize: unitSize }}>
413
+ {unit}
414
+ </span>
415
+ )}
416
+ </div>
417
+ {resolvedCaption !== undefined && resolvedCaption !== false && (
418
+ <div
419
+ className="mt-1 flex items-center gap-1 font-semibold leading-none"
420
+ style={{
421
+ color: band ? TONE_INK[activeTone] : 'rgb(var(--text-tertiary))',
422
+ fontSize: captionSize,
423
+ }}
424
+ >
425
+ {showBandIcon && band?.icon}
426
+ {resolvedCaption}
427
+ </div>
428
+ )}
429
+ </>
430
+ )}
431
+ </div>
432
+ )}
433
+ </div>
434
+ )
435
+ }
package/src/hue.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Board hue keys + the class helper for the derived tint ladder.
3
+ *
4
+ * ADR-146 D7 / `docs/design-briefs/tasks-board-design-language.md` §6.
5
+ *
6
+ * The ladder itself lives in `styles.css` (`.hue` + one rule per hue). This
7
+ * file is the typed door into it: lens code names a KEY, never a colour, and
8
+ * never a class string it composed itself.
9
+ *
10
+ * `red` is excluded on purpose — `--folder-red` is byte-identical to
11
+ * `--accent` in light mode, so a red lane would spend the view's single
12
+ * accent moment on a whole column of cards.
13
+ */
14
+
15
+ import type { FolderColorKey } from './folder-card'
16
+ import { FOLDER_COLOR_KEYS } from './folder-card'
17
+
18
+ /** The hues a board column may take. Every `--folder-*` key except `red`. */
19
+ export type BoardHueKey = Exclude<FolderColorKey, 'red'>
20
+
21
+ /** All board hue keys, in palette order — for swatch pickers. */
22
+ export const BOARD_HUE_KEYS: readonly BoardHueKey[] = FOLDER_COLOR_KEYS.filter(
23
+ (key): key is BoardHueKey => key !== 'red',
24
+ )
25
+
26
+ /**
27
+ * The class pair that arms the tint ladder on an element.
28
+ *
29
+ * `.hue` declares the six rungs against `--h`; `.hue-<key>` sets `--h`. Both
30
+ * are required, which is why this returns a pair rather than one class — the
31
+ * single most likely way to use the ladder wrong is to write `hue-blue` alone
32
+ * and get a slate card.
33
+ */
34
+ export function hueClass(key: BoardHueKey): string {
35
+ return `hue hue-${key}`
36
+ }
37
+
38
+ /**
39
+ * A stable hue for an arbitrary identity string (a person, an author, a lens).
40
+ *
41
+ * Deterministic so the same name is the same colour on every render and every
42
+ * machine — an avatar that changes colour between renders reads as a different
43
+ * person. Not a security or distribution primitive; a display hash.
44
+ */
45
+ export function hueForKey(seed: string): BoardHueKey {
46
+ let hash = 0
47
+ for (let i = 0; i < seed.length; i += 1) {
48
+ hash = (hash * 31 + seed.charCodeAt(i)) | 0
49
+ }
50
+ const index = Math.abs(hash) % BOARD_HUE_KEYS.length
51
+ return BOARD_HUE_KEYS[index] ?? 'slate'
52
+ }