@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
package/src/markdown.tsx CHANGED
@@ -18,7 +18,8 @@ import type {
18
18
  } from 'react'
19
19
  import ReactMarkdown, { type Components } from 'react-markdown'
20
20
  import remarkGfm from 'remark-gfm'
21
- import { MarkdownCode } from './code-block'
21
+ import { MarkdownCode, MarkdownPre } from './code-block'
22
+ import { remarkUnderline } from './lib/remark-underline'
22
23
 
23
24
  type NodeProps = HTMLAttributes<HTMLElement>
24
25
  type AnchorProps = AnchorHTMLAttributes<HTMLAnchorElement>
@@ -93,10 +94,11 @@ const MARKDOWN_COMPONENTS: Components = {
93
94
  {children}
94
95
  </em>
95
96
  ),
96
- // code: inline chip or fenced block (handled by CodeBlock via MarkdownCode).
97
- // pre just passes through so the highlighter owns its own container.
97
+ // code: inline chip or code block (CodeBlock via MarkdownCode). pre renders
98
+ // no element CodeBlock owns its container but it is what tells the code
99
+ // inside it that it is a block, language or not (see PreContext).
98
100
  code: MarkdownCode,
99
- pre: ({ children }: NodeProps) => <>{children}</>,
101
+ pre: MarkdownPre,
100
102
  blockquote: ({ children }: NodeProps) => (
101
103
  <blockquote
102
104
  className="my-4 border-l-4 py-1 pl-4 italic"
@@ -169,6 +171,19 @@ const MARKDOWN_COMPONENTS: Components = {
169
171
  {children}
170
172
  </del>
171
173
  ),
174
+ // Underline — `++text++` via remarkUnderline (ADR-148 D13). The line is a
175
+ // step softer than the ink so it reads as a deliberate underline, not a link.
176
+ u: ({ children }: NodeProps) => (
177
+ <u
178
+ className="underline-offset-2"
179
+ style={{
180
+ color: 'rgb(var(--foreground))',
181
+ textDecorationColor: 'rgb(var(--text-tertiary))',
182
+ }}
183
+ >
184
+ {children}
185
+ </u>
186
+ ),
172
187
  // Task-list checkboxes rendered by remark-gfm for "- [ ] ..." / "- [x] ...".
173
188
  input: (props: InputHTMLAttributes<HTMLInputElement>) =>
174
189
  props.type === 'checkbox' ? (
@@ -183,7 +198,10 @@ const MARKDOWN_COMPONENTS: Components = {
183
198
  ),
184
199
  }
185
200
 
186
- const REMARK_PLUGINS = [remarkGfm]
201
+ // THE remark plugin list. Every markdown surface in the package parses with
202
+ // this — `CommentBody` imports it rather than keeping its own, so GFM and the
203
+ // `++text++` underline (ADR-148 D13) cannot drift between surfaces.
204
+ const REMARK_PLUGINS = [remarkGfm, remarkUnderline]
187
205
 
188
206
  export interface MarkdownRendererProps {
189
207
  /** Raw markdown string to render. */
@@ -210,5 +228,6 @@ export function MarkdownRenderer({ children, className }: MarkdownRendererProps)
210
228
  )
211
229
  }
212
230
 
213
- // Re-export for callers that just want the raw component map
214
- export { MARKDOWN_COMPONENTS as markdownComponents }
231
+ // Re-export for callers that drive ReactMarkdown themselves (CommentBody):
232
+ // the raw component map and the plugin list, so both surfaces stay one.
233
+ export { MARKDOWN_COMPONENTS as markdownComponents, REMARK_PLUGINS as markdownRemarkPlugins }
package/src/modal.tsx CHANGED
@@ -89,9 +89,31 @@ interface ModalProps {
89
89
  * when `title` is non-empty — the `<h2>` names the dialog then.
90
90
  */
91
91
  ariaLabel?: string | undefined
92
+ /**
93
+ * Suppress the built-in header row (title + close) so the CALLER owns the
94
+ * whole chrome.
95
+ *
96
+ * Exists for one shape: a content component that must render identically
97
+ * inside `Modal` and `FloatingDrawer` (the Tasks item detail — one content
98
+ * component, two presentations). Without it that content carries its own
99
+ * header and the modal stacks a second, near-empty one with a second close
100
+ * button above it.
101
+ *
102
+ * The dialog still needs a NAME: pass `ariaLabel`, because there is no
103
+ * `<h2>` left to point `aria-labelledby` at.
104
+ */
105
+ hideHeader?: boolean | undefined
92
106
  }
93
107
 
94
- function Modal({ isOpen, onClose, title, children, className, ariaLabel }: ModalProps) {
108
+ function Modal({
109
+ isOpen,
110
+ onClose,
111
+ title,
112
+ children,
113
+ className,
114
+ ariaLabel,
115
+ hideHeader = false,
116
+ }: ModalProps) {
95
117
  const panelRef = useRef<HTMLDivElement | null>(null)
96
118
  const titleId = useId()
97
119
 
@@ -205,8 +227,8 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
205
227
  ref={panelRef}
206
228
  role="dialog"
207
229
  aria-modal="true"
208
- aria-labelledby={title ? titleId : undefined}
209
- aria-label={title ? undefined : ariaLabel}
230
+ aria-labelledby={title && !hideHeader ? titleId : undefined}
231
+ aria-label={title && !hideHeader ? undefined : ariaLabel}
210
232
  tabIndex={-1}
211
233
  data-framed="true"
212
234
  className={cn(
@@ -225,21 +247,23 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
225
247
  60px tall for a 23px title — and because the button was taller
226
248
  than the text, the BUTTON set the row height and the title
227
249
  floated in slack it never asked for. */}
228
- <div className="flex items-center justify-between gap-3 shrink-0 px-4 py-3">
229
- <h2
230
- id={titleId}
231
- className="text-[15px] font-bold tracking-[-0.01em] text-[rgb(var(--foreground))]"
232
- >
233
- {title}
234
- </h2>
235
- <button
236
- onClick={onClose}
237
- className="inline-flex items-center justify-center w-6 h-6 shrink-0 rounded-[var(--radius-sm)] text-[rgb(var(--text-tertiary))] hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))] transition-colors"
238
- aria-label="Close"
239
- >
240
- <X className="h-[15px] w-[15px]" />
241
- </button>
242
- </div>
250
+ {hideHeader ? null : (
251
+ <div className="flex items-center justify-between gap-3 shrink-0 px-4 py-3">
252
+ <h2
253
+ id={titleId}
254
+ className="text-[15px] font-bold tracking-[-0.01em] text-[rgb(var(--foreground))]"
255
+ >
256
+ {title}
257
+ </h2>
258
+ <button
259
+ onClick={onClose}
260
+ className="inline-flex items-center justify-center w-6 h-6 shrink-0 rounded-[var(--radius-sm)] text-[rgb(var(--text-tertiary))] hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))] transition-colors"
261
+ aria-label="Close"
262
+ >
263
+ <X className="h-[15px] w-[15px]" />
264
+ </button>
265
+ </div>
266
+ )}
243
267
  {renderBody(children)}
244
268
  </div>
245
269
  </div>
@@ -0,0 +1,304 @@
1
+ /**
2
+ * ProgressLedger — the per-task block bar.
3
+ *
4
+ * ADR-146's one piece of boldness, and the reason the port keeps a ledger
5
+ * rather than a percentage bar: it is COUNTABLE. You read 7 of 11 without
6
+ * reading a number, and each segment doubles as navigation into that group.
7
+ *
8
+ * Three rules it must not lose:
9
+ *
10
+ * 1. **Never colour-only.** The label row always carries the count AND the
11
+ * percentage as text (interface-standards §6), so completion is legible
12
+ * without perceiving hue.
13
+ * 2. **The track is grey structure, not a surface.** `--surface-inset` +
14
+ * 1px `--border`, exactly the "grey structure inside an opaque card" the
15
+ * data-display recipe requires (CLAUDE.md §2). Filled cells take the
16
+ * column hue off the tint ladder, so they composite opaque in both themes.
17
+ * 3. **A cell settles, it never pops.** Colour transitions over `--dur-base`
18
+ * (`.ds-ledger-cell` in styles.css) and `prefers-reduced-motion` jumps to
19
+ * the end state.
20
+ *
21
+ * Radius is the documented exception to the concentric ladder: cells are 3px
22
+ * because squareness is what makes them countable. The track is 5px so that
23
+ * 5 − 2px of padding = the cells' 3px and the chain still holds locally.
24
+ *
25
+ * The hue comes from an ancestor's `.hue hue-<key>` classes (see `hue.ts`);
26
+ * this primitive reads `--tint-fill` / `--tint-task` / `--h` and never names a
27
+ * colour of its own.
28
+ */
29
+
30
+ import type { CSSProperties, ReactNode } from 'react'
31
+ import { cn } from './lib/utils'
32
+ import { StatusRing } from './status'
33
+
34
+ /** One cell — a single task. */
35
+ export interface LedgerCell {
36
+ id: string
37
+ done: boolean
38
+ /** The task in flight. Rendered as an outlined cell, not a filled one. */
39
+ current?: boolean
40
+ /** Falls back to the cell id in the cell's title attribute. */
41
+ label?: string
42
+ }
43
+
44
+ /** One phase / group. A divider is drawn at every boundary between segments. */
45
+ export interface LedgerSegment {
46
+ id: string
47
+ label: string
48
+ cells: readonly LedgerCell[]
49
+ }
50
+
51
+ /** Done, total and the rounded percentage over a set of segments. */
52
+ export interface LedgerProgress {
53
+ done: number
54
+ total: number
55
+ /** 0–100, rounded. `0` when there is nothing to measure. */
56
+ percent: number
57
+ }
58
+
59
+ /**
60
+ * THE completion calculation — one function, every consumer.
61
+ *
62
+ * The ledger prints these numbers as text, the card's ring draws them, and a
63
+ * board column aggregates its lane's items through this same function. A
64
+ * second implementation anywhere is how a filled meter ends up disagreeing
65
+ * with the percentage printed beside it (header-primitives §1, last row).
66
+ */
67
+ export function ledgerProgress(segments: readonly LedgerSegment[]): LedgerProgress {
68
+ const cells = segments.flatMap((segment) => segment.cells)
69
+ const total = cells.length
70
+ const done = cells.filter((cell) => cell.done).length
71
+ return { done, total, percent: total === 0 ? 0 : Math.round((done / total) * 100) }
72
+ }
73
+
74
+ export interface ProgressLedgerProps {
75
+ segments: readonly LedgerSegment[]
76
+ /** Leading word in the label row. Sentence case. */
77
+ label?: string
78
+ /** Optional middle term — the phase the item is in. */
79
+ phase?: string
80
+ /** `lg` is the detail-drawer size; `sm` is the card size. */
81
+ size?: 'sm' | 'lg'
82
+ /**
83
+ * Emitted when a segment is activated. Interactivity is OPT-IN: without
84
+ * this, segments render as inert `<span>`s and add no tab stops.
85
+ *
86
+ * Supplying it is a commitment to a destination. A board that wires an empty
87
+ * handler here puts one dead 12px-tall tab stop on the page per segment per
88
+ * card — six of the Tasks board's twenty tab stops did exactly that — which
89
+ * is worse than the control being absent. When it IS supplied, the segment
90
+ * is forced to a 24px minimum hit area (interface-standards §6), so it
91
+ * cannot come back as a 12px target.
92
+ */
93
+ onSelectSegment?: (segmentId: string) => void
94
+ /**
95
+ * Draws a `<StatusRing>` at the head of the label row, showing the SAME
96
+ * percentage this ledger prints. It is decorative by construction — the
97
+ * number is already text two words away, and announcing it twice is the
98
+ * defect, not the feature.
99
+ */
100
+ showRing?: boolean
101
+ /** Names the ledger for assistive tech, e.g. the item title. */
102
+ ariaLabel?: string
103
+ className?: string
104
+ }
105
+
106
+ const TRACK_RADIUS = '5px'
107
+ const CELL_RADIUS = '3px'
108
+
109
+ export function ProgressLedger({
110
+ segments,
111
+ label = 'Progress',
112
+ phase,
113
+ size = 'sm',
114
+ onSelectSegment,
115
+ showRing = false,
116
+ ariaLabel,
117
+ className,
118
+ }: ProgressLedgerProps) {
119
+ const { done, total, percent } = ledgerProgress(segments)
120
+ const cellHeight = size === 'lg' ? 22 : 12
121
+ const cellMinWidth = size === 'lg' ? 12 : 5
122
+
123
+ return (
124
+ <div
125
+ className={cn('flex flex-col', className)}
126
+ style={{ gap: 'var(--space-1)' }}
127
+ role="group"
128
+ aria-label={
129
+ ariaLabel
130
+ ? `${label} for ${ariaLabel}: ${done} of ${total} done, ${percent} percent`
131
+ : `${label}: ${done} of ${total} done, ${percent} percent`
132
+ }
133
+ >
134
+ {/* Label row — `Progress · 7/11 · 64%`. The count is the reason the
135
+ ledger beats a bar, so it is never dropped for space. */}
136
+ <div
137
+ className="flex items-baseline text-[11px] leading-none"
138
+ style={{ gap: 'var(--space-2)', color: 'rgb(var(--text-muted))' }}
139
+ >
140
+ {showRing ? (
141
+ <StatusRing
142
+ percent={percent}
143
+ size={size === 'lg' ? 16 : 13}
144
+ strokeWidth={2}
145
+ decorative
146
+ // The 11px label row sits on its baseline; a ring has none, so it
147
+ // is nudged optically rather than mathematically.
148
+ style={{ alignSelf: 'center', marginBlockStart: '-1px' }}
149
+ />
150
+ ) : null}
151
+ <span>{label}</span>
152
+ {/* min-width slots: the card must not resize when a task is checked
153
+ and `9/11` becomes `10/11` (layout-stability rule). */}
154
+ <LedgerFigure style={{ minInlineSize: '3.5ch' }}>
155
+ {done}/{total}
156
+ </LedgerFigure>
157
+ {phase ? <span className="truncate">{phase}</span> : null}
158
+ <LedgerFigure
159
+ className="ms-auto font-semibold"
160
+ style={{
161
+ color: 'rgb(var(--muted-foreground))',
162
+ minInlineSize: '4ch',
163
+ textAlign: 'end',
164
+ }}
165
+ >
166
+ {percent}%
167
+ </LedgerFigure>
168
+ </div>
169
+
170
+ <div
171
+ className="flex items-stretch"
172
+ style={{
173
+ gap: '2px',
174
+ padding: '2px',
175
+ background: 'rgb(var(--surface-inset))',
176
+ border: '1px solid rgb(var(--border))',
177
+ borderRadius: TRACK_RADIUS,
178
+ }}
179
+ >
180
+ {total === 0 ? (
181
+ <span
182
+ className="block w-full"
183
+ style={{ height: cellHeight, borderRadius: CELL_RADIUS }}
184
+ aria-hidden="true"
185
+ />
186
+ ) : null}
187
+ {segments.map((segment, index) => (
188
+ <SegmentRun
189
+ key={segment.id}
190
+ segment={segment}
191
+ showDivider={index > 0}
192
+ cellHeight={cellHeight}
193
+ cellMinWidth={cellMinWidth}
194
+ onSelect={onSelectSegment}
195
+ />
196
+ ))}
197
+ </div>
198
+ </div>
199
+ )
200
+ }
201
+
202
+ function SegmentRun({
203
+ segment,
204
+ showDivider,
205
+ cellHeight,
206
+ cellMinWidth,
207
+ onSelect,
208
+ }: {
209
+ segment: LedgerSegment
210
+ showDivider: boolean
211
+ cellHeight: number
212
+ cellMinWidth: number
213
+ onSelect?: ((segmentId: string) => void) | undefined
214
+ }) {
215
+ const doneCount = segment.cells.filter((cell) => cell.done).length
216
+ const cells = (
217
+ <>
218
+ {segment.cells.map((cell) => (
219
+ <span
220
+ key={cell.id}
221
+ className="ds-ledger-cell block"
222
+ title={cell.label ?? undefined}
223
+ style={{
224
+ flex: '1 1 0',
225
+ minWidth: cellMinWidth,
226
+ height: cellHeight,
227
+ borderRadius: CELL_RADIUS,
228
+ background: cell.done
229
+ ? 'var(--tint-fill)'
230
+ : cell.current
231
+ ? 'var(--tint-task)'
232
+ : 'rgb(var(--surface-card))',
233
+ boxShadow: cell.current && !cell.done ? 'inset 0 0 0 1px var(--h)' : undefined,
234
+ }}
235
+ />
236
+ ))}
237
+ </>
238
+ )
239
+
240
+ const runStyle = {
241
+ flex: `${Math.max(segment.cells.length, 1)} 1 0`,
242
+ display: 'flex',
243
+ gap: '2px',
244
+ minWidth: 0,
245
+ } as const
246
+
247
+ return (
248
+ <>
249
+ {/* Phase boundary. A 1px rule, not a gap — a gap at this scale reads as
250
+ a missing cell and would make the count wrong at a glance. */}
251
+ {showDivider ? (
252
+ <span
253
+ aria-hidden="true"
254
+ style={{
255
+ flex: 'none',
256
+ width: '1px',
257
+ background: 'rgb(var(--border-strong))',
258
+ marginBlock: '-2px',
259
+ marginInline: '2px',
260
+ }}
261
+ />
262
+ ) : null}
263
+ {onSelect ? (
264
+ <button
265
+ type="button"
266
+ onClick={() => onSelect(segment.id)}
267
+ aria-label={`${segment.label}: ${doneCount} of ${segment.cells.length} done`}
268
+ className="rounded-[3px] focus-visible:[box-shadow:var(--ring-focus)] focus-visible:outline-none"
269
+ style={{
270
+ ...runStyle,
271
+ // The accessibility floor, enforced in the primitive rather than
272
+ // asked of every caller: a 12px cell row is not a 24px target, so
273
+ // the run grows to 24px and centres its cells inside it.
274
+ alignItems: 'center',
275
+ minBlockSize: '24px',
276
+ background: 'transparent',
277
+ border: 'none',
278
+ padding: 0,
279
+ }}
280
+ >
281
+ {cells}
282
+ </button>
283
+ ) : (
284
+ <span style={runStyle}>{cells}</span>
285
+ )}
286
+ </>
287
+ )
288
+ }
289
+
290
+ function LedgerFigure({
291
+ children,
292
+ className,
293
+ style,
294
+ }: {
295
+ children: ReactNode
296
+ className?: string
297
+ style?: CSSProperties
298
+ }) {
299
+ return (
300
+ <span className={cn('tabular-nums', className)} style={style}>
301
+ {children}
302
+ </span>
303
+ )
304
+ }