@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,252 @@
1
+ /**
2
+ * Thread fixture — the HARD cases.
3
+ *
4
+ * CLAUDE.md §7: shared mock data lives in a fixture file, never inline in a
5
+ * test. This one is written against ADR-147's acceptance criteria rather than
6
+ * against a pleasant screenshot, so every entry is here because something
7
+ * plausible breaks on it:
8
+ *
9
+ * • a 4-deep chain — the render cap, and what "Continue
10
+ * this thread" has to reach
11
+ * • 8 replies on one parent — 3 visible, 5 hidden (D-port)
12
+ * • a DELETED parent with live children — D4, the tombstone that keeps its
13
+ * subtree instead of orphaning it
14
+ * • a MODERATED tombstone — FU-0032, the second way a comment
15
+ * becomes one; `moderated` is all
16
+ * that separates it from the first
17
+ * • a reply whose parent is ABSENT — D4's other half: "not fetched" is
18
+ * not "deleted", and must be labelled
19
+ * • a pending and a failed send — D17
20
+ * • 2 images and a PDF — D9, including a declared aspect
21
+ * • 6 distinct reactions, 1 the viewer's — D8
22
+ * • a user mention AND a task mention — D11
23
+ * • an edited comment — D15
24
+ * • a 2,000-character body — D5 / acceptance 7
25
+ * • a one-word body — D5 / acceptance 7
26
+ * • a null author, a null display name,
27
+ * no avatar, and a BROKEN avatar src — D5 / acceptance 7
28
+ *
29
+ * Every timestamp is derived from FIXTURE_NOW, so the fixture is frozen and
30
+ * the relative labels it produces are assertable (D16, CLAUDE.md §7).
31
+ */
32
+
33
+ import type { ThreadComment } from '../../types'
34
+
35
+ /** 2026-09-06T12:00:00.000Z. Pass this as `now` and the clock cannot move. */
36
+ export const FIXTURE_NOW = Date.UTC(2026, 8, 6, 12, 0, 0)
37
+
38
+ const MINUTE = 60_000
39
+ const HOUR = 60 * MINUTE
40
+ const DAY = 24 * HOUR
41
+
42
+ function ago(ms: number): number {
43
+ return FIXTURE_NOW - ms
44
+ }
45
+
46
+ /** Exactly 2,000 characters, deterministically. */
47
+ export const LONG_BODY: string = 'The flighting plan holds through the end of the quarter. '
48
+ .repeat(40)
49
+ .slice(0, 2000)
50
+
51
+ export const BROKEN_AVATAR_SRC = 'https://assets.example.invalid/missing-avatar.png'
52
+
53
+ export const THREAD_FIXTURE: readonly ThreadComment[] = [
54
+ // ── 6 reactions, one of them the viewer's. Author has no avatar at all. ──
55
+ {
56
+ id: 'c-reactions',
57
+ author: { id: 'usr_marcus', name: 'Marcus Webb', role: 'Strategy' },
58
+ bodyMd: 'Pulling the second flight forward a week gives us the full holiday run-up.',
59
+ createdAt: ago(6 * MINUTE),
60
+ pinned: true,
61
+ reactions: [
62
+ { key: 'up', count: 7, viewerReacted: true },
63
+ { key: 'heart', count: 3, viewerReacted: false },
64
+ { key: 'celebrate', count: 2, viewerReacted: false },
65
+ { key: 'eyes', count: 4, viewerReacted: false },
66
+ { key: 'check', count: 1, viewerReacted: false },
67
+ { key: 'question', count: 2, viewerReacted: false },
68
+ ],
69
+ },
70
+
71
+ // ── Mentions: one person, one task item. Edited. Broken avatar src. ──
72
+ {
73
+ id: 'c-mentions',
74
+ author: {
75
+ id: 'usr_dana',
76
+ name: 'Dana Ortiz',
77
+ avatarUrl: BROKEN_AVATAR_SRC,
78
+ tint: 'blue',
79
+ },
80
+ bodyMd:
81
+ '@[Marcus Webb](user:usr_marcus) this is blocked on @[Slice B migration](task_item:tit_44a) landing first. Link for context: [the plan](https://example.com/plan).',
82
+ createdAt: ago(42 * MINUTE),
83
+ editedAt: ago(38 * MINUTE),
84
+ reactions: [{ key: 'eyes', count: 2, viewerReacted: false }],
85
+ },
86
+
87
+ // ── Attachments: two images (one with a declared aspect) and a PDF. ──
88
+ {
89
+ id: 'c-attachments',
90
+ author: { id: 'usr_priya', name: 'Priya Nair' },
91
+ bodyMd: 'Both cut-downs plus the signed spec.',
92
+ createdAt: ago(3 * HOUR),
93
+ attachments: [
94
+ {
95
+ id: 'att-1',
96
+ kind: 'image',
97
+ name: 'hero-16x9.jpg',
98
+ url: 'https://assets.example.invalid/hero-16x9.jpg',
99
+ mime: 'image/jpeg',
100
+ sizeBytes: 482_113,
101
+ aspectRatio: 16 / 9,
102
+ },
103
+ {
104
+ id: 'att-2',
105
+ kind: 'image',
106
+ name: 'story-9x16.jpg',
107
+ url: 'https://assets.example.invalid/story-9x16.jpg',
108
+ mime: 'image/jpeg',
109
+ sizeBytes: 511_902,
110
+ aspectRatio: 9 / 16,
111
+ },
112
+ {
113
+ id: 'att-3',
114
+ kind: 'file',
115
+ name: 'creative-spec.pdf',
116
+ url: 'https://assets.example.invalid/creative-spec.pdf',
117
+ mime: 'application/pdf',
118
+ sizeBytes: 1_884_160,
119
+ },
120
+ ],
121
+ },
122
+
123
+ // ── D4: a DELETED parent that keeps its subtree. ──
124
+ {
125
+ id: 'c-deleted-parent',
126
+ author: { id: 'usr_gone', name: 'Sam Petrov' },
127
+ // The API blanks the body on read; the fixture reflects what arrives.
128
+ bodyMd: '',
129
+ createdAt: ago(5 * HOUR),
130
+ deletedAt: ago(4 * HOUR),
131
+ },
132
+ {
133
+ id: 'c-deleted-child-a',
134
+ parentId: 'c-deleted-parent',
135
+ // D5: the author record is gone entirely (ON DELETE SET NULL).
136
+ author: null,
137
+ bodyMd: 'Agreed, and the same applies to the Q4 flight.',
138
+ createdAt: ago(4 * HOUR + 50 * MINUTE),
139
+ },
140
+ {
141
+ id: 'c-deleted-child-b',
142
+ parentId: 'c-deleted-parent',
143
+ // D5: a real user with NO display name — only an email.
144
+ author: { id: 'usr_noname', name: null, email: 'jordan@example.com' },
145
+ bodyMd: 'Still true after the rebrief?',
146
+ createdAt: ago(4 * HOUR + 20 * MINUTE),
147
+ },
148
+
149
+ // ── FU-0032: a tombstone somebody ELSE made. Structurally identical to
150
+ // the one above — same blanked body, same `deletedAt` — and `moderated`
151
+ // is the entire difference, which is the point: without it the two are
152
+ // the same row and the reader is told the wrong thing about one of them.
153
+ // The author is still withheld, and the wire never carries who removed it.
154
+ {
155
+ id: 'c-moderated',
156
+ author: { id: 'usr_kai', name: 'Kai Moreau' },
157
+ bodyMd: '',
158
+ createdAt: ago(5 * HOUR + 30 * MINUTE),
159
+ deletedAt: ago(3 * HOUR),
160
+ moderated: true,
161
+ },
162
+
163
+ // ── D4's other half: the parent is ABSENT from this page, not deleted. ──
164
+ {
165
+ id: 'c-orphan',
166
+ parentId: 'c-not-on-this-page',
167
+ author: { id: 'usr_lee', name: 'Lee Ndiaye' },
168
+ bodyMd: 'Following on from the earlier note about pacing.',
169
+ createdAt: ago(6 * HOUR),
170
+ },
171
+
172
+ // ── A four-level chain: root + 3 nested. Exercises the depth cap. ──
173
+ {
174
+ id: 'c-deep-0',
175
+ author: { id: 'usr_marcus', name: 'Marcus Webb' },
176
+ bodyMd: 'Level one.',
177
+ createdAt: ago(2 * DAY),
178
+ },
179
+ {
180
+ id: 'c-deep-1',
181
+ parentId: 'c-deep-0',
182
+ author: { id: 'usr_dana', name: 'Dana Ortiz' },
183
+ bodyMd: 'Level two.',
184
+ createdAt: ago(2 * DAY - 10 * MINUTE),
185
+ },
186
+ {
187
+ id: 'c-deep-2',
188
+ parentId: 'c-deep-1',
189
+ author: { id: 'usr_priya', name: 'Priya Nair' },
190
+ bodyMd: 'Level three.',
191
+ createdAt: ago(2 * DAY - 20 * MINUTE),
192
+ },
193
+ {
194
+ id: 'c-deep-3',
195
+ parentId: 'c-deep-2',
196
+ author: { id: 'usr_lee', name: 'Lee Ndiaye' },
197
+ bodyMd: 'Level four, past the render cap.',
198
+ createdAt: ago(2 * DAY - 30 * MINUTE),
199
+ },
200
+
201
+ // ── Eight replies on one parent: 3 visible, 5 hidden. ──
202
+ {
203
+ id: 'c-many',
204
+ author: { id: 'usr_priya', name: 'Priya Nair' },
205
+ bodyMd: 'Dropping the full list of markets below.',
206
+ createdAt: ago(3 * DAY),
207
+ },
208
+ ...Array.from({ length: 8 }, (_, index): ThreadComment => ({
209
+ id: `c-many-${String(index + 1)}`,
210
+ parentId: 'c-many',
211
+ author: { id: 'usr_marcus', name: 'Marcus Webb' },
212
+ bodyMd: `Market ${String(index + 1)} confirmed.`,
213
+ createdAt: ago(3 * DAY - (index + 1) * MINUTE),
214
+ })),
215
+
216
+ // ── A 2,000-character body and a one-word body. ──
217
+ {
218
+ id: 'c-long',
219
+ author: { id: 'usr_dana', name: 'Dana Ortiz' },
220
+ bodyMd: LONG_BODY,
221
+ createdAt: ago(4 * DAY),
222
+ },
223
+ {
224
+ id: 'c-one-word',
225
+ author: { id: 'usr_lee', name: 'Lee Ndiaye' },
226
+ bodyMd: 'Shipped.',
227
+ createdAt: ago(5 * DAY),
228
+ },
229
+
230
+ // ── D17: an optimistic send in flight, and one that failed. ──
231
+ {
232
+ id: 'c-pending',
233
+ author: { id: 'usr_ada', name: 'Ada Whitfield' },
234
+ bodyMd: 'Adding the revised pacing now.',
235
+ createdAt: ago(2 * MINUTE),
236
+ state: 'pending',
237
+ },
238
+ {
239
+ id: 'c-failed',
240
+ author: { id: 'usr_ada', name: 'Ada Whitfield' },
241
+ bodyMd: 'This one never made it off the device.',
242
+ createdAt: ago(4 * MINUTE),
243
+ state: 'failed',
244
+ errorText: 'This comment did not send.',
245
+ },
246
+ ]
247
+
248
+ /** The viewer, for composer avatars and reaction ownership. */
249
+ export const FIXTURE_VIEWER = {
250
+ id: 'usr_ada',
251
+ name: 'Ada Whitfield',
252
+ } as const
@@ -0,0 +1,120 @@
1
+ // @vitest-environment node
2
+ //
3
+ // Pure source scan — no DOM needed, and jsdom rewrites `import.meta.url` to an
4
+ // http URL that `fileURLToPath` refuses. Same pragma, same reason, as
5
+ // `token-shape.test.ts`.
6
+ //
7
+ /**
8
+ * The half of ADR-150 AC 32 that a rendering test structurally cannot cover.
9
+ *
10
+ * A canvas tainted by a cross-origin `og:image` DISPLAYS perfectly. It throws
11
+ * only when it is READ — `toDataURL()`, `getImageData()`, `toBlob()` — so a
12
+ * green render proves nothing about the defect this criterion exists to
13
+ * prevent. The runtime spy in `link-preview.test.tsx` proves the current code
14
+ * does not read it back; this proves no future refactor can add the read
15
+ * without a failing test, which is the durable half.
16
+ *
17
+ * AC 29 rides along for the same structural reason: "the built client contains
18
+ * no HTML parser and the link card adds no TipTap code to a thread list" is a
19
+ * statement about the module's IMPORTS, and the cheapest honest place to assert
20
+ * it is the import list itself.
21
+ */
22
+ import { readFileSync } from 'node:fs'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { describe, expect, it } from 'vitest'
25
+
26
+ const SOURCE = readFileSync(
27
+ fileURLToPath(new URL('../link-preview.tsx', import.meta.url)),
28
+ 'utf8',
29
+ )
30
+
31
+ /**
32
+ * The module with its prose removed.
33
+ *
34
+ * Every negative assertion below runs against this rather than the raw file,
35
+ * because the module's own documentation NAMES the things it must not do -
36
+ * "`crossOrigin=\"anonymous\"` is deliberately NOT set", "`toDataURL()` throws".
37
+ * Scanning the comments would either fail on a correct file or force the
38
+ * documentation to stop being specific, and the specificity is the point.
39
+ */
40
+ const CODE = SOURCE.replace(/\/\*[\s\S]*?\*\//g, '')
41
+ .split('\n')
42
+ .filter((line) => !/^\s*(?:\/\/|\*)/.test(line))
43
+ .join('\n')
44
+
45
+ describe('AC 32 - the still-frame canvas is drawn and never read back', () => {
46
+ it('contains no canvas read-back call', () => {
47
+ // The module's comments NAME all three APIs deliberately, so these match a
48
+ // CALL rather than a mention.
49
+ expect(CODE).not.toMatch(/\.toDataURL\s*\(/)
50
+ expect(CODE).not.toMatch(/\.getImageData\s*\(/)
51
+ expect(CODE).not.toMatch(/\.toBlob\s*\(/)
52
+ })
53
+
54
+ it('draws the frame it displays', () => {
55
+ // The positive half: removing `drawImage` would leave an empty canvas and
56
+ // an always-blank still, which no assertion about absence would catch.
57
+ expect(CODE).toMatch(/\.drawImage\s*\(/)
58
+ })
59
+
60
+ it('never sets crossOrigin on the hotlinked image', () => {
61
+ // It would untaint the canvas AND make every origin that sends no CORS
62
+ // header fail to load at all — a working card traded for a broken one on
63
+ // most of the web (D4, review amendment).
64
+ expect(CODE).not.toMatch(/crossOrigin\s*=/i)
65
+ // And the file still SAYS why, so the next reader does not re-add it.
66
+ expect(SOURCE).toMatch(/crossOrigin/)
67
+ })
68
+ })
69
+
70
+ describe('AC 29 - the card carries no editor, no parser, and no fetch client', () => {
71
+ it('imports nothing from TipTap, ProseMirror, or a markdown pipeline', () => {
72
+ const specifiers = [...CODE.matchAll(/^import[\s\S]*?from '([^']+)'/gm)].map((m) => m[1])
73
+ expect(specifiers.length).toBeGreaterThan(0)
74
+ for (const specifier of specifiers) {
75
+ expect(specifier).not.toMatch(/tiptap|prosemirror|react-markdown|remark|rehype/i)
76
+ }
77
+ })
78
+
79
+ it('holds no URL and makes no request', () => {
80
+ // A primitive that fetched would be a primitive with a base URL in it
81
+ // (CLAUDE.md §3). Every URL this renders arrives as data.
82
+ expect(CODE).not.toMatch(/\bfetch\s*\(/)
83
+ expect(CODE).not.toMatch(/https?:\/\//)
84
+ })
85
+
86
+ it('renders no raw markup', () => {
87
+ expect(CODE).not.toMatch(/dangerouslySetInnerHTML/)
88
+ expect(CODE).not.toMatch(/innerHTML/)
89
+ })
90
+
91
+ it('uses Lucide icons rather than inline SVG', () => {
92
+ // CLAUDE.md §6. `packages/ui/` may own an SVG definition; a link card is
93
+ // not one of the places that should.
94
+ expect(CODE).not.toMatch(/<svg/)
95
+ expect(CODE).toMatch(/from 'lucide-react'/)
96
+ })
97
+ })
98
+
99
+ describe('CLAUDE.md section 2 - the surface is the primitive, not a forked div', () => {
100
+ it('renders a Card and never uses a washed-out tint as the surface', () => {
101
+ expect(CODE).toMatch(/from '\.\.\/card'/)
102
+ expect(CODE).toMatch(/<Card/)
103
+ // The two tokens the owner's hard rule names. `--muted` is a small muted
104
+ // FILL and `--surface-overlay*` is a tint on an already-opaque surface;
105
+ // neither is ever a card/panel background.
106
+ expect(CODE).not.toMatch(/background:\s*'rgb\(var\(--surface-overlay/)
107
+ expect(CODE).not.toMatch(/background:\s*'rgb\(var\(--muted\)\)'/)
108
+ })
109
+
110
+ it('carries no colour literal and no raw spacing or radius value', () => {
111
+ // CLAUDE.md section 4: colour literals live only in tokens.css, and every
112
+ // spacing/radius value snaps to a tier.
113
+ expect(CODE).not.toMatch(/#[0-9a-fA-F]{3,8}\b/)
114
+ expect(CODE).not.toMatch(/\b(?:rgb|rgba|oklch|hsl)\(\s*(?!var\()/)
115
+ for (const property of ['padding', 'margin', 'gap', 'borderRadius', 'inset']) {
116
+ const raw = new RegExp(`${property}[^,;\\n]*:\\s*'[^']*\\d+(?:px|rem|em)`, 'i')
117
+ expect(CODE).not.toMatch(raw)
118
+ }
119
+ })
120
+ })