@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,303 @@
1
+ /**
2
+ * Tabs — the WAI-ARIA APG keyboard contract.
3
+ *
4
+ * `role="tablist"` shipped with none of the behaviour the role promises: no
5
+ * roving tabindex (every tab was its own tab stop), no arrow keys, and no
6
+ * `id` / `aria-controls` linking a tab to anything. These pin the four halves
7
+ * of the fix — movement + wrap, Home/End, the roving tabindex values, and
8
+ * that an emitted `aria-controls` actually resolves to a rendered element.
9
+ */
10
+ import { useState } from 'react'
11
+ import { describe, expect, it, vi } from 'vitest'
12
+ import { render, screen } from '@testing-library/react'
13
+ import userEvent from '@testing-library/user-event'
14
+ import { Tabs, type Tab } from '../tabs'
15
+ import { SegmentedChoice } from '../detail/segmented-choice'
16
+
17
+ const TABS: Tab[] = [
18
+ { id: 'overview', label: 'Overview' },
19
+ { id: 'activity', label: 'Activity' },
20
+ { id: 'settings', label: 'Settings' },
21
+ ]
22
+
23
+ /** Controlled harness — the component is controlled, so the test owns state. */
24
+ function Harness({
25
+ tabs = TABS,
26
+ initial = 'overview',
27
+ variant,
28
+ onTabChange,
29
+ }: {
30
+ tabs?: Tab[]
31
+ initial?: string
32
+ variant?: 'underline' | 'pill' | 'segmented'
33
+ onTabChange?: (id: string) => void
34
+ }) {
35
+ const [active, setActive] = useState(initial)
36
+ return (
37
+ <Tabs
38
+ idPrefix="demo"
39
+ {...(variant === undefined ? {} : { variant })}
40
+ tabs={tabs}
41
+ activeTabId={active}
42
+ onTabChange={(id) => {
43
+ setActive(id)
44
+ onTabChange?.(id)
45
+ }}
46
+ />
47
+ )
48
+ }
49
+
50
+ const tab = (name: string) => screen.getByRole('tab', { name })
51
+
52
+ describe('Tabs', () => {
53
+ describe('roving tabindex', () => {
54
+ it('puts exactly one tab in the tab ring, and it is the selected one', () => {
55
+ render(<Harness initial="activity" />)
56
+ expect(tab('Overview')).toHaveAttribute('tabindex', '-1')
57
+ expect(tab('Activity')).toHaveAttribute('tabindex', '0')
58
+ expect(tab('Settings')).toHaveAttribute('tabindex', '-1')
59
+ expect(
60
+ screen.getAllByRole('tab').filter((node) => node.getAttribute('tabindex') === '0'),
61
+ ).toHaveLength(1)
62
+ })
63
+
64
+ it('never emits a positive tabindex', () => {
65
+ render(<Harness />)
66
+ for (const node of screen.getAllByRole('tab')) {
67
+ expect(Number(node.getAttribute('tabindex'))).toBeLessThanOrEqual(0)
68
+ }
69
+ })
70
+
71
+ it('falls back to the first enabled tab when the selected one is disabled', () => {
72
+ render(
73
+ <Harness
74
+ initial="overview"
75
+ tabs={[
76
+ { id: 'overview', label: 'Overview', disabled: true },
77
+ { id: 'activity', label: 'Activity' },
78
+ ]}
79
+ />,
80
+ )
81
+ expect(tab('Activity')).toHaveAttribute('tabindex', '0')
82
+ expect(tab('Overview')).toHaveAttribute('tabindex', '-1')
83
+ })
84
+
85
+ it('moves the tab stop with the selection', async () => {
86
+ render(<Harness />)
87
+ tab('Overview').focus()
88
+ await userEvent.keyboard('{ArrowRight}')
89
+ expect(tab('Activity')).toHaveAttribute('tabindex', '0')
90
+ expect(tab('Overview')).toHaveAttribute('tabindex', '-1')
91
+ })
92
+ })
93
+
94
+ describe('arrow keys', () => {
95
+ it('ArrowRight moves focus and selects the next tab (automatic activation)', async () => {
96
+ const onTabChange = vi.fn()
97
+ render(<Harness onTabChange={onTabChange} />)
98
+ tab('Overview').focus()
99
+ await userEvent.keyboard('{ArrowRight}')
100
+ expect(tab('Activity')).toHaveFocus()
101
+ expect(tab('Activity')).toHaveAttribute('aria-selected', 'true')
102
+ expect(onTabChange).toHaveBeenCalledWith('activity')
103
+ })
104
+
105
+ it('ArrowLeft moves the other way', async () => {
106
+ render(<Harness initial="settings" />)
107
+ tab('Settings').focus()
108
+ await userEvent.keyboard('{ArrowLeft}')
109
+ expect(tab('Activity')).toHaveFocus()
110
+ expect(tab('Activity')).toHaveAttribute('aria-selected', 'true')
111
+ })
112
+
113
+ it('wraps past the end', async () => {
114
+ render(<Harness initial="settings" />)
115
+ tab('Settings').focus()
116
+ await userEvent.keyboard('{ArrowRight}')
117
+ expect(tab('Overview')).toHaveFocus()
118
+ expect(tab('Overview')).toHaveAttribute('aria-selected', 'true')
119
+ })
120
+
121
+ it('wraps past the start', async () => {
122
+ render(<Harness />)
123
+ tab('Overview').focus()
124
+ await userEvent.keyboard('{ArrowLeft}')
125
+ expect(tab('Settings')).toHaveFocus()
126
+ expect(tab('Settings')).toHaveAttribute('aria-selected', 'true')
127
+ })
128
+
129
+ it('steps over a disabled tab rather than stranding focus on it', async () => {
130
+ render(
131
+ <Harness
132
+ tabs={[
133
+ { id: 'overview', label: 'Overview' },
134
+ { id: 'activity', label: 'Activity', disabled: true },
135
+ { id: 'settings', label: 'Settings' },
136
+ ]}
137
+ />,
138
+ )
139
+ tab('Overview').focus()
140
+ await userEvent.keyboard('{ArrowRight}')
141
+ expect(tab('Settings')).toHaveFocus()
142
+ })
143
+ })
144
+
145
+ describe('Home / End', () => {
146
+ it('Home jumps to the first tab', async () => {
147
+ render(<Harness initial="settings" />)
148
+ tab('Settings').focus()
149
+ await userEvent.keyboard('{Home}')
150
+ expect(tab('Overview')).toHaveFocus()
151
+ expect(tab('Overview')).toHaveAttribute('aria-selected', 'true')
152
+ })
153
+
154
+ it('End jumps to the last tab', async () => {
155
+ render(<Harness />)
156
+ tab('Overview').focus()
157
+ await userEvent.keyboard('{End}')
158
+ expect(tab('Settings')).toHaveFocus()
159
+ expect(tab('Settings')).toHaveAttribute('aria-selected', 'true')
160
+ })
161
+ })
162
+
163
+ describe('panel wiring', () => {
164
+ it('gives every tab a stable id built from idPrefix', () => {
165
+ render(<Harness />)
166
+ expect(tab('Overview')).toHaveAttribute('id', 'demo-tab-overview')
167
+ expect(tab('Settings')).toHaveAttribute('id', 'demo-tab-settings')
168
+ })
169
+
170
+ it('emits no aria-controls when the consumer renders no panel', () => {
171
+ render(<Harness />)
172
+ expect(tab('Overview')).not.toHaveAttribute('aria-controls')
173
+ })
174
+
175
+ it('resolves aria-controls to a rendered element when panelId is supplied', () => {
176
+ render(
177
+ <div>
178
+ <Tabs
179
+ idPrefix="demo"
180
+ tabs={[
181
+ { id: 'overview', label: 'Overview', panelId: 'demo-panel-overview' },
182
+ { id: 'activity', label: 'Activity', panelId: 'demo-panel-activity' },
183
+ ]}
184
+ activeTabId="overview"
185
+ onTabChange={() => {}}
186
+ />
187
+ <div
188
+ id="demo-panel-overview"
189
+ role="tabpanel"
190
+ aria-labelledby="demo-tab-overview"
191
+ tabIndex={0}
192
+ >
193
+ Overview panel
194
+ </div>
195
+ </div>,
196
+ )
197
+ const controls = tab('Overview').getAttribute('aria-controls')
198
+ expect(controls).toBe('demo-panel-overview')
199
+ const panel = document.getElementById(controls ?? '')
200
+ expect(panel).not.toBeNull()
201
+ expect(panel).toHaveAttribute('role', 'tabpanel')
202
+ // ...and the panel names its tab back, which resolves too.
203
+ expect(document.getElementById('demo-tab-overview')).toBe(tab('Overview'))
204
+ })
205
+ })
206
+
207
+ /**
208
+ * The `segmented` variant exists so the activity pane's strip can sit
209
+ * flush with the field-list header in the next pane. That is a HEIGHT
210
+ * claim, and the height is built the same way in both places — a 26px
211
+ * segment inside a 4px-padded, 1px-bordered track. jsdom lays nothing
212
+ * out, so what these pin is the construction the height comes from: if
213
+ * someone changes the track padding on one and not the other, the two
214
+ * headers stop lining up and this fails.
215
+ */
216
+ describe('the segmented variant', () => {
217
+ const boxOf = (el: Element) => ({
218
+ padding: /p-\[var\(--space-1\)\]/.test(el.className),
219
+ border: /(^|\s)border(\s|$)/.test(el.className),
220
+ radius: (el as HTMLElement).style.borderRadius,
221
+ })
222
+
223
+ it('builds the same box as SegmentedChoice, so the two headers align', () => {
224
+ const { unmount } = render(
225
+ <Tabs
226
+ variant="segmented"
227
+ stretch
228
+ tabs={TABS}
229
+ activeTabId="overview"
230
+ onTabChange={() => {}}
231
+ />,
232
+ )
233
+ const track = screen.getByRole('tablist')
234
+ const segment = screen.getByRole('tab', { name: 'Overview' })
235
+ expect(boxOf(track)).toEqual({ padding: true, border: true, radius: 'var(--radius-md)' })
236
+ expect(segment.className).toContain('h-[26px]')
237
+ unmount()
238
+
239
+ render(
240
+ <SegmentedChoice
241
+ ariaLabel="Filter fields"
242
+ value="all"
243
+ onChange={() => {}}
244
+ items={[
245
+ { id: 'all', label: 'All' },
246
+ { id: 'filled', label: 'Filled' },
247
+ ]}
248
+ />,
249
+ )
250
+ const choice = screen.getByRole('group', { name: 'Filter fields' })
251
+ expect(boxOf(choice)).toEqual({ padding: true, border: true, radius: 'var(--radius-md)' })
252
+ expect(screen.getByRole('button', { name: 'All' }).className).toContain('h-[26px]')
253
+ })
254
+
255
+ it('fills its container with equal-width segments', () => {
256
+ render(
257
+ <Tabs
258
+ variant="segmented"
259
+ stretch
260
+ tabs={TABS}
261
+ activeTabId="overview"
262
+ onTabChange={() => {}}
263
+ />,
264
+ )
265
+ expect(screen.getByRole('tablist').className).toContain('w-full')
266
+ for (const label of ['Overview', 'Activity', 'Settings']) {
267
+ expect(screen.getByRole('tab', { name: label }).className).toContain('flex-1')
268
+ }
269
+ })
270
+
271
+ it('keeps a focus ring — the one variant with no underline to lose', () => {
272
+ // The active segment carries `--shadow-sm`. As an INLINE style that
273
+ // would beat `focus-visible:`, silently; as a class it does not.
274
+ render(
275
+ <Tabs
276
+ variant="segmented"
277
+ stretch
278
+ tabs={TABS}
279
+ activeTabId="overview"
280
+ onTabChange={() => {}}
281
+ />,
282
+ )
283
+ const active = screen.getByRole('tab', { name: 'Overview' })
284
+ expect(active.getAttribute('style')).toBeNull()
285
+ expect(active.className).toContain('[box-shadow:var(--shadow-sm)]')
286
+ expect(active.className).toContain('focus-visible:[box-shadow:var(--ring-focus)]')
287
+ })
288
+
289
+ it('is still a tablist — the keyboard model comes with it', async () => {
290
+ const user = userEvent.setup()
291
+ render(
292
+ <Harness variant="segmented" tabs={TABS} />,
293
+ )
294
+ await user.tab()
295
+ await user.keyboard('{ArrowRight}')
296
+ expect(screen.getByRole('tab', { name: 'Activity' })).toHaveAttribute(
297
+ 'aria-selected',
298
+ 'true',
299
+ )
300
+ })
301
+ })
302
+ })
303
+
@@ -137,15 +137,46 @@ describe('token shape', () => {
137
137
  expect(violations).toEqual([])
138
138
  })
139
139
 
140
- it('never references a token that tokens.css does not define', () => {
140
+ it('never references a custom property nothing in the package defines', () => {
141
+ // A COMPONENT-LOCAL custom property is not a token, and a reference to one
142
+ // is not the typo this rule hunts. `.ds-avatar` declares `--avatar-hue` /
143
+ // `--avatar-size` and reads them back, so a hue or size modifier is one
144
+ // declaration instead of a repeated block; `--avatar-ring` exists precisely
145
+ // so the HOST can hand the primitive a colour the primitive cannot know
146
+ // (ADR-147 D14). Requiring those in tokens.css would mean either banning
147
+ // component-local properties outright or padding the shared vocabulary with
148
+ // entries nothing outside one component may use.
149
+ //
150
+ // So the rule is: a var() must resolve to a tokens.css token OR to a custom
151
+ // property declared somewhere in this package. Package-wide rather than
152
+ // per-file because the declaring rule and the reading element routinely sit
153
+ // in different files — a class in `styles.css` setting a property that a
154
+ // `.tsx` reads is the normal shape. A misspelt name is still declared
155
+ // nowhere and still fails, which is the whole point.
156
+ const declared = new Set(
157
+ FILES.flatMap((file) =>
158
+ readCode(file).flatMap((line) =>
159
+ [...line.matchAll(/(--[a-z0-9-]+)\s*:/g)].map(([, name]) => name!),
160
+ ),
161
+ ),
162
+ )
141
163
  const violations: string[] = []
142
164
  for (const file of FILES) {
143
165
  readCode(file).forEach((line, i) => {
166
+ // BOTH forms are checked. An earlier pass excluded `var(--x, fallback)`
167
+ // on the theory that a fallback declares the property optional — but it
168
+ // also means a typo (`var(--surfce-card, white)`) renders the fallback
169
+ // forever with nothing to catch it. A genuine override knob declares
170
+ // itself and its default instead; see `--board-column-max-block-size`
171
+ // in styles.css.
144
172
  for (const [, token] of line.matchAll(/var\((--[a-z0-9-]+)[,)]/g)) {
145
173
  // Tailwind's own custom properties are not ours to define.
146
174
  if (token!.startsWith('--tw-')) continue
175
+ if (declared.has(token!)) continue
147
176
  if (!TOKENS.has(token!)) {
148
- violations.push(`${rel(file)}:${i + 1} — var(${token}) is not defined in tokens.css`)
177
+ violations.push(
178
+ `${rel(file)}:${i + 1} — var(${token}) is declared nowhere in packages/ui`,
179
+ )
149
180
  }
150
181
  }
151
182
  })
@@ -191,3 +222,102 @@ describe('undefined utility classes', () => {
191
222
  expect(violations).toEqual([])
192
223
  })
193
224
  })
225
+
226
+ describe('a mark is never painted in a surface token', () => {
227
+ /**
228
+ * The defect this pins was MEASURED in a browser, not read out of the
229
+ * source: `GaugeRing` defaulted its track to `rgb(var(--muted))`, and
230
+ * `--muted` is declared as `var(--surface-inset)` — the recessed-well
231
+ * surface. A gauge dropped into a well therefore stroked its track in
232
+ * exactly the colour behind it: rgb(242,242,244) on rgb(242,242,244) in
233
+ * light and rgb(45,45,48) on rgb(45,45,48) in dark. **1.00:1 in both
234
+ * themes** — a 0% gauge rendered as bare text with no ring at all.
235
+ *
236
+ * Nothing about that is visible to typecheck, lint, build or a DOM-shape
237
+ * test: the attribute string `rgb(var(--muted))` is perfectly well-formed.
238
+ * What IS statically checkable is the relationship — that a token used as
239
+ * a MARK must not be an alias of a SURFACE. So this resolves the alias
240
+ * chain by name and asserts the mark never lands on one.
241
+ *
242
+ * Mutation check (ADR-146 D8's "a guard never observed failing is not a
243
+ * guard"): put `--muted` back as the default in `gauge-ring.tsx` and this
244
+ * test goes red, naming the alias chain it walked.
245
+ */
246
+
247
+ /**
248
+ * The surfaces a primitive may SIT ON. A mark may never be one of these.
249
+ *
250
+ * `--muted` is in the list because it aliases `--surface-inset`. That does
251
+ * NOT ban `--muted` as a small muted FILL — `RankedBars` fills its track
252
+ * with it and puts a `--border` stroke around it, which is the sanctioned
253
+ * shape (CLAUDE.md §2) and survives on any surface because the outline
254
+ * carries it. The scan below only reads named mark-COLOUR prop defaults,
255
+ * so that pattern is not matched.
256
+ */
257
+ const SURFACE_TOKENS = new Set([
258
+ '--surface-card',
259
+ '--surface-frame',
260
+ '--surface-inset',
261
+ '--bg-card',
262
+ '--muted',
263
+ ])
264
+
265
+ /** Follow `--a: var(--b)` until it stops being a pure alias. */
266
+ function resolveAlias(name: string): string[] {
267
+ const chain = [name]
268
+ let current = name
269
+ for (let hop = 0; hop < 8; hop += 1) {
270
+ const values = TOKENS.get(current)
271
+ if (values === undefined) break
272
+ // A pure alias is a value that is nothing but one var() reference.
273
+ const targets = values
274
+ .map((v) => /^var\((--[a-z0-9-]+)\)$/.exec(v.trim())?.[1])
275
+ .filter((v): v is string => v !== undefined)
276
+ const aliases = new Set(targets)
277
+ // Only follow when EVERY theme block declares the SAME alias. A token
278
+ // that aliases in one theme and is a literal in the other is a
279
+ // different problem, and stopping here reports it rather than
280
+ // silently walking one theme's chain.
281
+ if (aliases.size !== 1 || targets.length !== values.length) break
282
+ const next = [...aliases][0]!
283
+ if (chain.includes(next)) break
284
+ chain.push(next)
285
+ current = next
286
+ }
287
+ return chain
288
+ }
289
+
290
+ /**
291
+ * Every `<name> = 'rgb(var(--token))'` default in a primitive whose name
292
+ * says it paints a mark — a track, a stroke, a fill, a rule.
293
+ */
294
+ const MARK_DEFAULT =
295
+ /\b(trackColor|strokeColor|fillColor|ruleColor|barColor)\s*=\s*'rgb\(var\((--[a-z0-9-]+)\)\)'/g
296
+
297
+ it('resolves no mark default onto a surface token', () => {
298
+ const violations: string[] = []
299
+ for (const file of FILES) {
300
+ const source = readCode(file).join('\n')
301
+ for (const [, prop, token] of source.matchAll(MARK_DEFAULT)) {
302
+ const chain = resolveAlias(token!)
303
+ const hit = chain.find((step) => SURFACE_TOKENS.has(step))
304
+ if (hit !== undefined) {
305
+ violations.push(
306
+ `${rel(file)} — ${prop} defaults to ${token}, which resolves ` +
307
+ `${chain.join(' -> ')}; ${hit} is a SURFACE. A mark painted in ` +
308
+ `the surface it may sit on is invisible (measured 1.00:1).`,
309
+ )
310
+ }
311
+ }
312
+ }
313
+ expect(violations).toEqual([])
314
+ })
315
+
316
+ it('still recognises --muted as a surface alias (the guard, inverted)', () => {
317
+ // Proves the resolver actually walks the chain rather than passing
318
+ // because it found nothing. If `--muted` ever stops aliasing
319
+ // `--surface-inset`, THIS is what tells us the guard above went blind.
320
+ expect(resolveAlias('--muted')).toContain('--surface-inset')
321
+ expect(resolveAlias('--border-strong')).toEqual(['--border-strong'])
322
+ })
323
+ })