@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
|
@@ -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
|
+
}
|