@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,175 @@
1
+ /**
2
+ * StatStrip — the divider trick and the reserved slots.
3
+ *
4
+ * jsdom has no layout, so nothing here can measure a wrap. What it CAN pin is
5
+ * the MECHANISM that makes the wrap correct, which is the part a future
6
+ * refactor is most likely to "simplify" back into the bug:
7
+ *
8
+ * • Every cell carries BOTH a block-start and an inline-start border, and
9
+ * the row cancels the outermost ones with a one-hairline negative offset
10
+ * inside a clipping card. Replace that with `:not(:last-child)` borders
11
+ * and a stray vertical rule appears at the start of every wrapped row —
12
+ * which no gate reads the source can see, and which these assertions do.
13
+ * • The footer slot is reserved for EVERY cell as soon as ANY cell owns one,
14
+ * so a late-arriving delta cannot grow the strip (ADR-146 D9).
15
+ * • A single cell needs no special case: its two borders are the outermost
16
+ * ones, so they are clipped and it renders with no dividers at all.
17
+ */
18
+ import { describe, expect, it } from 'vitest'
19
+ import { render, screen } from '@testing-library/react'
20
+
21
+ import { StatStrip, type StatStripCell } from '../stat-strip'
22
+
23
+ // Named rather than indexed out of an array: `noUncheckedIndexedAccess`
24
+ // would otherwise make every `CELLS[0]` an `| undefined` that needs an `as`,
25
+ // and a cast in a test is a cast in the repo.
26
+ const BUDGET: StatStripCell = {
27
+ key: 'budget',
28
+ label: 'Total budget',
29
+ value: '$1,204,000',
30
+ }
31
+ const FLIGHTED: StatStripCell = {
32
+ key: 'flighted',
33
+ label: 'Flighted',
34
+ value: '$1,180,500',
35
+ }
36
+ const VARIANCE: StatStripCell = {
37
+ key: 'variance',
38
+ label: 'Variance',
39
+ value: '-$23,500',
40
+ }
41
+ const CELLS: StatStripCell[] = [BUDGET, FLIGHTED, VARIANCE]
42
+
43
+ function rowOf(container: HTMLElement): HTMLDListElement {
44
+ const row = container.querySelector('dl')
45
+ if (row === null) throw new Error('StatStrip rendered no <dl>')
46
+ return row
47
+ }
48
+
49
+ function cellsOf(container: HTMLElement): HTMLElement[] {
50
+ return Array.from(rowOf(container).children).filter(
51
+ (el): el is HTMLElement => el instanceof HTMLElement,
52
+ )
53
+ }
54
+
55
+ describe('StatStrip', () => {
56
+ it('renders one labelled cell per stat, as a description list', () => {
57
+ render(<StatStrip cells={CELLS} ariaLabel="Plan totals" />)
58
+ expect(screen.getByLabelText('Plan totals').tagName).toBe('DL')
59
+ // The label wraps in a span so a long localized string can wrap without
60
+ // pushing the info icon around; the semantic element is still the <dt>.
61
+ expect(screen.getByText('Total budget').closest('dt')).not.toBeNull()
62
+ expect(screen.getByText('$1,204,000').tagName).toBe('DD')
63
+ })
64
+
65
+ it('renders nothing at all for an empty strip', () => {
66
+ const { container } = render(<StatStrip cells={[]} />)
67
+ expect(container).toBeEmptyDOMElement()
68
+ })
69
+
70
+ it('gives EVERY cell both dividers — never `:not(:last-child)`', () => {
71
+ const { container } = render(<StatStrip cells={CELLS} />)
72
+ const cells = cellsOf(container)
73
+ expect(cells).toHaveLength(3)
74
+ for (const cell of cells) {
75
+ expect(cell.style.borderBlockStart).toContain('rgb(var(--border))')
76
+ expect(cell.style.borderInlineStart).toContain('rgb(var(--border))')
77
+ }
78
+ })
79
+
80
+ it('cancels the outermost dividers with a clipped one-hairline offset', () => {
81
+ const { container } = render(<StatStrip cells={CELLS} />)
82
+ const row = rowOf(container)
83
+ expect(row.style.marginBlockStart).toBe('-1px')
84
+ expect(row.style.marginInlineStart).toBe('-1px')
85
+ // The clip is what turns those offsets into "no outer border". Without
86
+ // it the strip grows a doubled top and start edge.
87
+ expect(row.parentElement).toHaveClass('overflow-hidden')
88
+ })
89
+
90
+ it('needs no special case for a single cell', () => {
91
+ const { container } = render(<StatStrip cells={[BUDGET]} />)
92
+ const cells = cellsOf(container)
93
+ expect(cells).toHaveLength(1)
94
+ // Same borders as any other cell — both are the outermost ones, so both
95
+ // are clipped and the cell renders with no dividers.
96
+ expect(cells[0]?.style.borderInlineStart).toContain('rgb(var(--border))')
97
+ })
98
+
99
+ it('wraps by content, in the strip’s own width, not a device preset', () => {
100
+ const { container } = render(<StatStrip cells={CELLS} minCellWidth={240} />)
101
+ for (const cell of cellsOf(container)) {
102
+ // Identical basis + identical grow: cells in a row stay equal, and the
103
+ // row breaks where the content stops fitting.
104
+ expect(cell.style.flex).toBe('1 1 240px')
105
+ expect(cell).toHaveClass('min-w-0')
106
+ }
107
+ })
108
+
109
+ it('reserves the footer slot in every cell when any cell has a delta', () => {
110
+ const cells: StatStripCell[] = [
111
+ { ...BUDGET, delta: { value: 12.5 } },
112
+ FLIGHTED,
113
+ ]
114
+ const { container } = render(<StatStrip cells={cells} />)
115
+ for (const cell of cellsOf(container)) {
116
+ const slots = cell.querySelectorAll('dd')
117
+ expect(slots).toHaveLength(2)
118
+ expect(slots[1]?.style.minHeight).toBe('var(--space-6)')
119
+ }
120
+ })
121
+
122
+ it('renders no footer slot at all when no cell has one', () => {
123
+ const { container } = render(<StatStrip cells={CELLS} />)
124
+ for (const cell of cellsOf(container)) {
125
+ expect(cell.querySelectorAll('dd')).toHaveLength(1)
126
+ }
127
+ })
128
+
129
+ it('reserves a value slot so a ticking number cannot resize anything', () => {
130
+ render(<StatStrip cells={CELLS} valueChars={11} />)
131
+ expect(screen.getByText('$1,204,000')).toHaveStyle({ minWidth: '11ch' })
132
+ })
133
+
134
+ it('reads the info hint ONCE, and still shows it on hover', () => {
135
+ const { container } = render(
136
+ <StatStrip cells={[{ ...BUDGET, info: 'Sum of all line items' }]} />,
137
+ )
138
+ // Exactly one node carries the text. `role="img" aria-label` + `title`
139
+ // would name the icon AND describe it with the same sentence, so an
140
+ // accname-correct reader announces it twice.
141
+ expect(screen.getAllByText('Sum of all line items')).toHaveLength(1)
142
+ expect(
143
+ container.querySelector('[title="Sum of all line items"]'),
144
+ ).not.toBeNull()
145
+ })
146
+
147
+ it('forces the footer slot on before any delta has arrived', () => {
148
+ // The derivation cannot reserve for data that does not exist yet: with
149
+ // every `delta` still undefined it reserves nothing, and the slot pops in
150
+ // — growing the strip — the moment the fetch lands. `reserveFooter` is
151
+ // how a caller says "they are coming" (ADR-146 D9).
152
+ const { container } = render(<StatStrip cells={CELLS} reserveFooter />)
153
+ for (const cell of cellsOf(container)) {
154
+ const slots = cell.querySelectorAll('dd')
155
+ expect(slots).toHaveLength(2)
156
+ expect(slots[1]?.style.minHeight).toBe('var(--space-6)')
157
+ }
158
+ })
159
+
160
+ it('hides the reserved slot from AT in the cells that are only spacing', () => {
161
+ const cells: StatStripCell[] = [
162
+ { ...BUDGET, delta: { value: 12.5 } },
163
+ FLIGHTED,
164
+ ]
165
+ const { container } = render(<StatStrip cells={cells} />)
166
+ const [withDelta, withoutDelta] = cellsOf(container)
167
+ expect(
168
+ withDelta?.querySelectorAll('dd')[1]?.getAttribute('aria-hidden'),
169
+ ).toBeNull()
170
+ // An empty <dd> is announced as an empty description of the stat above it.
171
+ expect(
172
+ withoutDelta?.querySelectorAll('dd')[1]?.getAttribute('aria-hidden'),
173
+ ).toBe('true')
174
+ })
175
+ })
@@ -0,0 +1,293 @@
1
+ /**
2
+ * The status vocabulary + the hidden-columns rail
3
+ * (`docs/design-briefs/tasks-board-header-primitives.md` §2 and §3).
4
+ *
5
+ * What is pinned here is the pair of things the source app got wrong and that
6
+ * no typecheck, lint or build can see:
7
+ *
8
+ * 1. **The ring measures.** Its arc is derived from the percent it was
9
+ * given, by the documented formula, clamped rather than wrapped. The
10
+ * constants below are computed by hand, not by re-running the component's
11
+ * own maths — a test that recomputes the formula it is testing proves
12
+ * nothing.
13
+ * 2. **The rail can only exist with both maps.** A lane with 0 visible and
14
+ * 5 total leaves the strip and reads `0 / 5`; the same lane with no
15
+ * `totalItems` at all cannot be distinguished from an ordinary empty one,
16
+ * so it must not claim to be hiding anything.
17
+ */
18
+
19
+ import { describe, expect, it, vi } from 'vitest'
20
+ import { fireEvent, render, screen, within } from '@testing-library/react'
21
+ import { Board } from '../board'
22
+ import { ProgressLedger, ledgerProgress } from '../progress-ledger'
23
+ import { BOARD_STATUS_KEYS, STATUS_GLYPHS, STATUS_LABELS, StatusGlyph, StatusRing } from '../status'
24
+
25
+ /** The progress arc is the LAST circle — the first is the track. */
26
+ function arc(container: HTMLElement): SVGCircleElement {
27
+ const circles = container.querySelectorAll('circle')
28
+ const last = circles[circles.length - 1]
29
+ if (!last) throw new Error('no arc rendered')
30
+ return last as unknown as SVGCircleElement
31
+ }
32
+
33
+ function track(container: HTMLElement): SVGCircleElement {
34
+ const first = container.querySelector('circle')
35
+ if (!first) throw new Error('no track rendered')
36
+ return first as unknown as SVGCircleElement
37
+ }
38
+
39
+ const num = (el: Element, attr: string) => Number(el.getAttribute(attr))
40
+
41
+ describe('StatusRing — the arc actually measures', () => {
42
+ // size 40, stroke 4 -> r = 18, circumference = 2π·18 = 113.0973…
43
+ const CIRC = 113.09733552923255
44
+
45
+ it('draws the full circumference as the dash array', () => {
46
+ const { container } = render(<StatusRing percent={0} size={40} strokeWidth={4} />)
47
+ expect(num(arc(container), 'stroke-dasharray')).toBeCloseTo(CIRC, 4)
48
+ })
49
+
50
+ it('offsets by circumference · (1 − percent/100)', () => {
51
+ const { container } = render(<StatusRing percent={25} size={40} strokeWidth={4} />)
52
+ // 113.0973… × 0.75 = 84.8230…
53
+ expect(num(arc(container), 'stroke-dashoffset')).toBeCloseTo(84.82300164692441, 4)
54
+ })
55
+
56
+ it('closes the arc at 100 and draws nothing at 0', () => {
57
+ const full = render(<StatusRing percent={100} size={40} strokeWidth={4} />)
58
+ expect(num(arc(full.container), 'stroke-dashoffset')).toBeCloseTo(0, 6)
59
+
60
+ const none = render(<StatusRing percent={0} size={40} strokeWidth={4} />)
61
+ expect(num(arc(none.container), 'stroke-dashoffset')).toBeCloseTo(CIRC, 4)
62
+ })
63
+
64
+ it('clamps out-of-range input rather than wrapping it', () => {
65
+ // 140% must not read as 40%, and −20% must not read as 80%.
66
+ const over = render(<StatusRing percent={140} size={40} strokeWidth={4} />)
67
+ expect(num(arc(over.container), 'stroke-dashoffset')).toBeCloseTo(0, 6)
68
+
69
+ const under = render(<StatusRing percent={-20} size={40} strokeWidth={4} />)
70
+ expect(num(arc(under.container), 'stroke-dashoffset')).toBeCloseTo(CIRC, 4)
71
+ })
72
+
73
+ it('starts at twelve o’clock with a round cap', () => {
74
+ const { container } = render(<StatusRing percent={50} size={40} strokeWidth={4} />)
75
+ expect(arc(container).getAttribute('transform')).toBe('rotate(-90 20 20)')
76
+ expect(arc(container).getAttribute('stroke-linecap')).toBe('round')
77
+ })
78
+
79
+ it('runs the track on --border and the arc on the hue ladder', () => {
80
+ const { container } = render(<StatusRing percent={50} size={40} strokeWidth={4} />)
81
+ expect(track(container).getAttribute('stroke')).toBe('rgb(var(--border))')
82
+ expect(arc(container).getAttribute('stroke')).toBe('var(--h, rgb(var(--folder-slate)))')
83
+ })
84
+
85
+ it('names itself with the percent, and arms the ladder when given a hue', () => {
86
+ const { container } = render(<StatusRing percent={64} hue="blue" />)
87
+ expect(screen.getByRole('img', { name: '64% complete' })).toBeInTheDocument()
88
+ expect(container.querySelector('.hue.hue-blue')).not.toBeNull()
89
+ })
90
+
91
+ it('is silent when a text percentage sits beside it', () => {
92
+ // Announced twice is the defect, not the feature.
93
+ render(<StatusRing percent={64} decorative />)
94
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
95
+ })
96
+ })
97
+
98
+ describe('StatusGlyph — discrete state, never colour alone', () => {
99
+ it('covers every state with a lucide glyph and an operator label', () => {
100
+ expect(BOARD_STATUS_KEYS).toHaveLength(6)
101
+ for (const key of BOARD_STATUS_KEYS) {
102
+ expect(STATUS_GLYPHS[key]).toBeDefined()
103
+ expect(STATUS_LABELS[key].length).toBeGreaterThan(0)
104
+ // It really renders an <svg>, and nothing here is a hand-written one:
105
+ // every glyph is a lucide component (CLAUDE.md §6).
106
+ const { container, unmount } = render(<StatusGlyph status={key} />)
107
+ expect(container.querySelector('svg.lucide')).not.toBeNull()
108
+ expect(screen.getByRole('img', { name: STATUS_LABELS[key] })).toBeInTheDocument()
109
+ unmount()
110
+ }
111
+ })
112
+
113
+ it('carries its meaning in its accessible name', () => {
114
+ render(<StatusGlyph status="done" />)
115
+ expect(screen.getByRole('img', { name: 'Done' })).toBeInTheDocument()
116
+ })
117
+
118
+ it('goes silent when an adjacent visible label already says it', () => {
119
+ render(
120
+ <span>
121
+ <StatusGlyph status="blocked" decorative />
122
+ Blocked
123
+ </span>,
124
+ )
125
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
126
+ })
127
+ })
128
+
129
+ describe('ledgerProgress — one calculation, every consumer', () => {
130
+ const segments = [
131
+ { id: 'g1', label: 'Design', cells: [{ id: 'a', done: true }, { id: 'b', done: true }] },
132
+ {
133
+ id: 'g2',
134
+ label: 'Build',
135
+ cells: [{ id: 'c', done: true }, { id: 'd', done: false }, { id: 'e', done: false }],
136
+ },
137
+ ]
138
+
139
+ it('counts and rounds', () => {
140
+ expect(ledgerProgress(segments)).toEqual({ done: 3, total: 5, percent: 60 })
141
+ })
142
+
143
+ it('does not divide by zero', () => {
144
+ expect(ledgerProgress([])).toEqual({ done: 0, total: 0, percent: 0 })
145
+ })
146
+
147
+ it('draws the ring at the same number the ledger prints', () => {
148
+ const { container } = render(<ProgressLedger segments={segments} showRing />)
149
+ expect(screen.getByText('60%')).toBeInTheDocument()
150
+ // size 13, stroke 2 -> r = 5.5, circumference = 34.5575…, 60% -> 13.8230…
151
+ expect(num(arc(container), 'stroke-dashoffset')).toBeCloseTo(13.823007675795088, 4)
152
+ // The number is text two words away, so the ring must not repeat it.
153
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
154
+ })
155
+ })
156
+
157
+ describe('Board — hidden columns need BOTH maps', () => {
158
+ const columns = [
159
+ { id: 'planned', label: 'Planned', hue: 'slate' as const },
160
+ { id: 'building', label: 'Building', hue: 'blue' as const },
161
+ ]
162
+
163
+ const renderBoard = (props: Partial<Parameters<typeof Board>[0]> = {}) =>
164
+ render(
165
+ <Board
166
+ columns={columns}
167
+ items={{ planned: [], building: ['b1'] }}
168
+ draggable={false}
169
+ renderCard={({ itemId }) => <span>{itemId}</span>}
170
+ {...props}
171
+ />,
172
+ )
173
+
174
+ it('stacks a filtered-out lane into the rail as `0 / n`', () => {
175
+ renderBoard({
176
+ totalItems: { planned: ['p1', 'p2', 'p3', 'p4', 'p5'], building: ['b1'] },
177
+ })
178
+
179
+ const rail = screen.getByRole('complementary', { name: 'Hidden columns' })
180
+ const row = within(rail).getByText('Planned').closest('li')
181
+ expect(row).not.toBeNull()
182
+ expect(within(row!).getByText('0 / 5')).toBeInTheDocument()
183
+ // Read out in words, not as "zero slash five".
184
+ expect(within(row!).getByText('0 of 5 shown')).toBeInTheDocument()
185
+
186
+ // It has LEFT the strip — it is not a column with a zero in it.
187
+ expect(screen.queryByRole('region', { name: 'Planned' })).not.toBeInTheDocument()
188
+ })
189
+
190
+ it('cannot claim anything is hidden without totalItems', () => {
191
+ renderBoard()
192
+ expect(screen.queryByRole('complementary', { name: 'Hidden columns' })).not.toBeInTheDocument()
193
+ expect(screen.queryByText(/hidden by filters/)).not.toBeInTheDocument()
194
+ })
195
+
196
+ // Two renders, two tests, because both share one document — asserting an
197
+ // absence after an earlier render has already put the lane there passes or
198
+ // fails for the wrong reason.
199
+ it('keeps a genuinely empty lane by default', () => {
200
+ // The default is to SHOW. An empty lane is part of the board's shape and is
201
+ // a drop target, so a consumer that never passes the prop must not lose a
202
+ // lane the moment it empties — that is the silent-vanish footgun. Hiding is
203
+ // the FILTERED case, which the hidden-columns rail owns instead.
204
+ renderBoard()
205
+ expect(screen.getByRole('region', { name: 'Planned' })).toBeInTheDocument()
206
+ })
207
+
208
+ it('omits an empty lane when the host asks it to', () => {
209
+ renderBoard({ showEmptyColumns: false })
210
+ expect(screen.queryByRole('region', { name: 'Planned' })).not.toBeInTheDocument()
211
+ })
212
+
213
+ it('renders the footer ONLY when a filter is actually hiding something', () => {
214
+ // The footer takes its height out of the board's, so an always-mounted one
215
+ // would shorten every lane for nothing (action-bar brief §7: "when absent
216
+ // the board area reclaims its height"). `totalItems` equal to `items` is
217
+ // the case a `filtersActive` flag would get wrong — a filter IS on, and it
218
+ // is hiding nothing.
219
+ const { container, unmount } = renderBoard({
220
+ totalItems: { planned: [], building: ['b1'] },
221
+ })
222
+ expect(screen.queryByText(/hidden by filters/)).not.toBeInTheDocument()
223
+ expect(container.querySelector('.ds-board-hidden-summary')).toBeNull()
224
+ // The shell stays out of the box tree entirely, so the lanes are laid out
225
+ // exactly as they are on a board that has no filtering at all.
226
+ expect(container.querySelector('.ds-board-shell[data-footer]')).toBeNull()
227
+ unmount()
228
+
229
+ const withHidden = renderBoard({ totalItems: { planned: [], building: ['b1', 'b2'] } })
230
+ expect(screen.getByText('1 item hidden by filters')).toBeInTheDocument()
231
+ expect(
232
+ withHidden.container.querySelector('.ds-board-shell[data-footer="true"]'),
233
+ ).not.toBeNull()
234
+ })
235
+
236
+ it('says how much is hidden in total, in the board’s own noun', () => {
237
+ renderBoard({
238
+ totalItems: { planned: ['p1', 'p2'], building: ['b1', 'b2', 'b3'] },
239
+ itemNoun: { one: 'project', many: 'projects' },
240
+ })
241
+ // 2 hidden in Planned + 2 in Building.
242
+ expect(screen.getByRole('status')).toHaveTextContent('4 projects hidden by filters')
243
+ })
244
+
245
+ it('uses the singular for one', () => {
246
+ renderBoard({ totalItems: { planned: ['p1'], building: ['b1'] } })
247
+ expect(screen.getByRole('status')).toHaveTextContent('1 item hidden by filters')
248
+ })
249
+
250
+ it('offers a way out, as a real button', () => {
251
+ const onClear = vi.fn()
252
+ renderBoard({ totalItems: { planned: ['p1'], building: ['b1'] }, onClearFilters: onClear })
253
+ fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }))
254
+ expect(onClear).toHaveBeenCalledOnce()
255
+ })
256
+
257
+ it('shows the count without a dead button when there is nowhere to clear to', () => {
258
+ renderBoard({ totalItems: { planned: ['p1'], building: ['b1'] } })
259
+ expect(screen.queryByRole('button', { name: 'Clear filters' })).not.toBeInTheDocument()
260
+ })
261
+
262
+ it('reads a reduced lane as `visible / total`, not as a bare count', () => {
263
+ renderBoard({ totalItems: { planned: ['p1'], building: ['b1', 'b2', 'b3'] } })
264
+ const building = screen.getByRole('region', { name: 'Building' })
265
+ expect(within(building).getByText('1 / 3')).toBeInTheDocument()
266
+ expect(within(building).getByText('1 of 3 shown')).toBeInTheDocument()
267
+ })
268
+
269
+ it('collapses the rail without losing it', () => {
270
+ renderBoard({ totalItems: { planned: ['p1', 'p2'], building: ['b1'] } })
271
+ const toggle = screen.getByRole('button', { name: /Hidden columns/ })
272
+ expect(toggle).toHaveAttribute('aria-expanded', 'true')
273
+ fireEvent.click(toggle)
274
+ expect(toggle).toHaveAttribute('aria-expanded', 'false')
275
+ expect(screen.getByRole('complementary', { name: 'Hidden columns' })).toBeInTheDocument()
276
+ })
277
+
278
+ it('marks a lane with a ring when it has a number, a glyph when it does not', () => {
279
+ render(
280
+ <Board
281
+ columns={[
282
+ { id: 'planned', label: 'Planned', hue: 'slate', progress: { done: 3, total: 4, percent: 75 } },
283
+ { id: 'building', label: 'Building', hue: 'blue', status: 'in-progress' },
284
+ ]}
285
+ items={{ planned: ['p1'], building: ['b1'] }}
286
+ draggable={false}
287
+ renderCard={({ itemId }) => <span>{itemId}</span>}
288
+ />,
289
+ )
290
+ expect(screen.getByRole('img', { name: 'Planned is 75% complete' })).toBeInTheDocument()
291
+ expect(screen.getByRole('img', { name: 'In progress' })).toBeInTheDocument()
292
+ })
293
+ })