@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- 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 +307 -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__/sortable.test.tsx +394 -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 +369 -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 +611 -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 +301 -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 +303 -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 +520 -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 +252 -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 +742 -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 +967 -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 +232 -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,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
|
+
}
|