@lovett/ui 0.0.11 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
|
@@ -0,0 +1,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
|
+
})
|