@sorb/seed 0.1.0 → 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.
@@ -0,0 +1,208 @@
1
+ // Tests for the capture token-annotator (auto-bind: property affinity + tier
2
+ // precedence). Run: node --test (zero-dep; resolves @sorb/core via the workspace link).
3
+ import { test } from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import {
6
+ normalizeColor,
7
+ classifyColor,
8
+ normalizeDimension,
9
+ buildTokenIndex,
10
+ matchColor,
11
+ matchDimension,
12
+ annotateTree,
13
+ } from './annotateTokens.js'
14
+
15
+ // A resolved map where one color value (#0f65ef) is shared across a component
16
+ // bg, a component border, a semantic bg, and a semantic border — so the tests
17
+ // exercise BOTH role affinity (which property) and tier precedence (the tiebreak).
18
+ const RESOLVED = [
19
+ { id: 'button.primary.bg.default', cssVar: '--button-primary-bg-default', value: '#0f65ef', tier: 'component', type: 'color' },
20
+ { id: 'button.primary.border.default', cssVar: '--button-primary-border-default', value: '#0F65EF', tier: 'component', type: 'color' },
21
+ { id: 'color.action.primary', cssVar: '--color-action-primary', value: '#0f65ef', tier: 'semantic', type: 'color' },
22
+ { id: 'color.border.default', cssVar: '--color-border-default', value: '#0f65ef', tier: 'semantic', type: 'color' },
23
+ { id: 'button.primary.text.default', cssVar: '--button-primary-text-default', value: '#ffffff', tier: 'component', type: 'color' },
24
+ { id: 'button.radius', cssVar: '--button-radius', value: '4px', tier: 'component', type: 'dimension' },
25
+ ]
26
+
27
+ test('normalizeColor handles hex (3/4/6/8), rgb(a), transparent, and rejects junk', () => {
28
+ assert.equal(normalizeColor('#fff'), '#ffffffff')
29
+ assert.equal(normalizeColor('#FFFF'), '#ffffffff')
30
+ assert.equal(normalizeColor('#0f65ef'), '#0f65efff')
31
+ assert.equal(normalizeColor('#0F65EF80'), '#0f65ef80')
32
+ assert.equal(normalizeColor('rgb(15, 101, 239)'), '#0f65efff')
33
+ assert.equal(normalizeColor('rgba(0,0,0,0)'), '#00000000')
34
+ assert.equal(normalizeColor('transparent'), '#00000000')
35
+ assert.equal(normalizeColor('not-a-color'), null)
36
+ assert.equal(normalizeColor(null), null)
37
+ })
38
+
39
+ test('normalizeDimension parses px / unitless / decimals, rejects non-lengths', () => {
40
+ assert.equal(normalizeDimension('4px'), 4)
41
+ assert.equal(normalizeDimension('4'), 4)
42
+ assert.equal(normalizeDimension(8), 8)
43
+ assert.equal(normalizeDimension('2.5px'), 2.5)
44
+ assert.equal(normalizeDimension('-1px'), -1)
45
+ assert.equal(normalizeDimension('1rem'), null)
46
+ assert.equal(normalizeDimension(null), null)
47
+ })
48
+
49
+ test('buildTokenIndex groups tokens by normalized value into colors/dims', () => {
50
+ const idx = buildTokenIndex(RESOLVED)
51
+ assert.equal(idx.colors.get('#0f65efff').length, 4) // four tokens share this color
52
+ assert.equal(idx.colors.get('#ffffffff').length, 1)
53
+ assert.equal(idx.dims.get(4).length, 1)
54
+ })
55
+
56
+ test('matchColor: role affinity picks the right property family', () => {
57
+ const idx = buildTokenIndex(RESOLVED)
58
+ // bg role → only the component bg carries `.bg`
59
+ assert.equal(matchColor(idx, '#0f65ef', 'bg').token, 'button.primary.bg.default')
60
+ // border role → two `.border` cands; tier tiebreak picks component over semantic
61
+ assert.equal(matchColor(idx, '#0f65ef', 'border').token, 'button.primary.border.default')
62
+ })
63
+
64
+ test('matchColor: all matches are kept as candidates regardless of the pick', () => {
65
+ const idx = buildTokenIndex(RESOLVED)
66
+ const res = matchColor(idx, '#0f65ef', 'bg')
67
+ assert.equal(res.candidates.length, 4)
68
+ assert.ok(res.candidates.includes('color.action.primary'))
69
+ })
70
+
71
+ test('matchColor: with no role, tier precedence wins (component beats semantic)', () => {
72
+ const idx = buildTokenIndex(RESOLVED)
73
+ const res = matchColor(idx, '#0f65ef')
74
+ assert.ok(['button.primary.bg.default', 'button.primary.border.default'].includes(res.token))
75
+ })
76
+
77
+ test('matchColor / matchDimension return null token for a value with no match', () => {
78
+ const idx = buildTokenIndex(RESOLVED)
79
+ assert.equal(matchColor(idx, '#123456', 'bg').token, null)
80
+ assert.equal(matchDimension(idx, 99, 'radius').token, null)
81
+ })
82
+
83
+ test('matchDimension binds a radius value to the component radius token', () => {
84
+ const idx = buildTokenIndex(RESOLVED)
85
+ assert.equal(matchDimension(idx, 4, 'radius').token, 'button.radius')
86
+ })
87
+
88
+ test('annotateTree binds a button: fill→bg, stroke→border, radius, TEXT child→text', () => {
89
+ const idx = buildTokenIndex(RESOLVED)
90
+ const node = {
91
+ type: 'FRAME',
92
+ fills: [{ raw: '#0f65ef' }],
93
+ strokes: [{ raw: '#0f65ef' }],
94
+ cornerRadius: 4,
95
+ children: [{ type: 'TEXT', fills: [{ raw: '#ffffff' }] }],
96
+ }
97
+ const out = annotateTree(node, idx)
98
+ assert.equal(out.sorb.tokens.fill, 'button.primary.bg.default')
99
+ assert.equal(out.sorb.tokens.stroke, 'button.primary.border.default')
100
+ assert.equal(out.sorb.tokens.cornerRadius, 'button.radius')
101
+ // a fill on a TEXT node is foreground → the `text` role
102
+ assert.equal(out.children[0].sorb.tokens.fill, 'button.primary.text.default')
103
+ })
104
+
105
+ test('annotateTree leaves unmatched nodes without a `sorb` key', () => {
106
+ const idx = buildTokenIndex(RESOLVED)
107
+ const node = { type: 'FRAME', fills: [{ raw: '#abcdef' }] }
108
+ const out = annotateTree(node, idx)
109
+ assert.equal(out.sorb, undefined)
110
+ })
111
+
112
+ // ── REC-6: CSS named-color support + unparseable/no-match marker ──────────────
113
+
114
+ test('REC-6: normalizeColor resolves common CSS named colors', () => {
115
+ assert.equal(normalizeColor('red'), '#ff0000ff')
116
+ assert.equal(normalizeColor('WHITE'), '#ffffffff') // case-insensitive
117
+ assert.equal(normalizeColor('rebeccapurple'), null) // not in the minimal table → no-match
118
+ assert.equal(normalizeColor('purple'), '#800080ff')
119
+ })
120
+
121
+ test('REC-6: classifyColor distinguishes ok / no-match / unparseable', () => {
122
+ assert.deepEqual(classifyColor('#0f65ef'), { hex: '#0f65efff', status: 'ok' })
123
+ assert.deepEqual(classifyColor('blue'), { hex: '#0000ffff', status: 'ok' })
124
+ // a `#`-prefixed but malformed value is recognized-but-unparseable, not just no-match
125
+ assert.equal(classifyColor('#ggg').status, 'unparseable')
126
+ assert.equal(classifyColor('#12').status, 'unparseable')
127
+ assert.equal(classifyColor('rgb(1,2)').status, 'unparseable')
128
+ assert.equal(classifyColor('rgb(x,y,z)').status, 'unparseable')
129
+ // a clean non-color (e.g. a dimension) is no-match, not unparseable
130
+ assert.equal(classifyColor('4px').status, 'no-match')
131
+ assert.equal(classifyColor('not-a-color').status, 'no-match')
132
+ assert.equal(classifyColor(null).status, 'no-match')
133
+ })
134
+
135
+ test('REC-6: normalizeColor tolerates exotic whitespace (NBSP) around a hex', () => {
136
+ assert.equal(normalizeColor(' #0f65ef '), '#0f65efff')
137
+ })
138
+
139
+ // ── REC-1: buildTokenIndex.dropped[] for vanished tokens ─────────────────────
140
+
141
+ test('REC-1: buildTokenIndex still destructures to {colors, dims} (backward-compat)', () => {
142
+ const { colors, dims, dropped } = buildTokenIndex(RESOLVED)
143
+ assert.ok(colors.get('#0f65efff'))
144
+ assert.ok(dims.get(4))
145
+ assert.deepEqual(dropped, []) // a clean map drops nothing
146
+ })
147
+
148
+ test('REC-1: an unparseable-color token is dropped with a marker reason', () => {
149
+ const { dropped } = buildTokenIndex([
150
+ { id: 'color.bad', cssVar: '--bad', value: '#ggg', tier: 'semantic', type: 'color' },
151
+ ])
152
+ assert.equal(dropped.length, 1)
153
+ assert.equal(dropped[0].id, 'color.bad')
154
+ assert.equal(dropped[0].reason, 'unparseable-color')
155
+ })
156
+
157
+ test('REC-1: a no-match token (NBSP-only / junk) is dropped as no-match', () => {
158
+ const { dropped } = buildTokenIndex([
159
+ { id: 'space.weird', cssVar: '--w', value: ' ', tier: 'primitive', type: 'dimension' },
160
+ ])
161
+ assert.equal(dropped.length, 1)
162
+ assert.equal(dropped[0].reason, 'no-match')
163
+ })
164
+
165
+ // ── REC-2: unresolved-alias / cycle detection ────────────────────────────────
166
+
167
+ test('REC-2: a still-wrapped {…} value is dropped as unresolved-alias (not thrown)', () => {
168
+ const { dropped, colors } = buildTokenIndex([
169
+ { id: 'color.action', cssVar: '--a', value: '{color.brand.primary}', tier: 'semantic', type: 'color' },
170
+ { id: 'color.brand.primary', cssVar: '--p', value: '#0f65ef', tier: 'primitive', type: 'color' },
171
+ ])
172
+ const alias = dropped.find((d) => d.id === 'color.action')
173
+ assert.ok(alias)
174
+ assert.equal(alias.reason, 'unresolved-alias')
175
+ // the real token still indexed normally
176
+ assert.ok(colors.get('#0f65efff'))
177
+ })
178
+
179
+ test('REC-2: an A↔B alias cycle is detected and dropped as alias-cycle', () => {
180
+ const { dropped } = buildTokenIndex([
181
+ { id: 'a', cssVar: '--a', value: '{b}', tier: 'semantic', type: 'color' },
182
+ { id: 'b', cssVar: '--b', value: '{a}', tier: 'semantic', type: 'color' },
183
+ ])
184
+ const reasons = dropped.map((d) => d.reason)
185
+ assert.equal(dropped.length, 2)
186
+ assert.ok(reasons.every((r) => r === 'alias-cycle'))
187
+ })
188
+
189
+ // ── pick() off-role fallback → low-confidence diagnostic ─────────────────────
190
+
191
+ test('off-role fallback bind is surfaced as a low-confidence diagnostic', () => {
192
+ // #ffffff only carries a `.text` token; binding it as a frame `bg` fill is off-role.
193
+ const idx = buildTokenIndex(RESOLVED)
194
+ const node = { type: 'FRAME', fills: [{ raw: '#ffffff' }] }
195
+ const out = annotateTree(node, idx)
196
+ assert.equal(out.sorb.tokens.fill, 'button.primary.text.default') // still binds
197
+ assert.ok(Array.isArray(out.sorb.diagnostics))
198
+ assert.equal(out.sorb.diagnostics[0].kind, 'off-role-bind')
199
+ assert.equal(out.sorb.diagnostics[0].detail.role, 'bg')
200
+ })
201
+
202
+ test('an on-role bind attaches no diagnostics (additive, only when needed)', () => {
203
+ const idx = buildTokenIndex(RESOLVED)
204
+ const node = { type: 'FRAME', fills: [{ raw: '#0f65ef' }] }
205
+ const out = annotateTree(node, idx)
206
+ assert.equal(out.sorb.tokens.fill, 'button.primary.bg.default')
207
+ assert.equal(out.sorb.diagnostics, undefined)
208
+ })
package/src/capture.js CHANGED
@@ -201,6 +201,106 @@ export const captureNode = (el, parentRect) => {
201
201
  /** Entry point: capture a root element with itself at the origin (0,0). */
202
202
  export const captureRoot = (el) => captureNode(el, el.getBoundingClientRect())
203
203
 
204
+ // ─── Capture root trim (sorb-capture-trim-spec.md) ──────────────────────────
205
+ // Storybook wraps each story in full-width padded divs, so a raw captured root
206
+ // is the story container (e.g. 1248×86) with the real component (button 82×38)
207
+ // nested at an offset inside empty wrappers. `tightenRoot` trims the tree to the
208
+ // meaningful component AT CAPTURE TIME so every consumer (insert, preview, label
209
+ // wrapper) gets a tight, token-bound node. Pure + annotation-safe: it runs in
210
+ // captureCli BEFORE annotateTree, and only drops un-annotated, non-visual
211
+ // wrappers, so token bindings on the kept subtree are untouched.
212
+
213
+ /**
214
+ * "Content-bearing" = the node, or any descendant, paints (a non-transparent
215
+ * fill or a stroke) or is TEXT. Same notion as the preview SVG walker.
216
+ * @param {object} node LayerNode
217
+ * @returns {boolean}
218
+ */
219
+ export const hasContent = (node) => {
220
+ if (!node) return false
221
+ if (node.type === 'TEXT') return true
222
+ if ((node.fills && node.fills[0]) || (node.strokes && node.strokes[0])) return true
223
+ return (node.children || []).some(hasContent)
224
+ }
225
+
226
+ /** A node that ITSELF paints (own fill/stroke) or is TEXT — vs a transparent wrapper. */
227
+ const selfPaints = (node) =>
228
+ node.type === 'TEXT' || !!(node.fills && node.fills[0]) || !!(node.strokes && node.strokes[0])
229
+
230
+ /**
231
+ * Union of the rects of all self-painting (or TEXT) nodes in `node`'s subtree,
232
+ * expressed in `node`'s LOCAL coordinate space (node's own origin = 0,0).
233
+ * Returns null when nothing in the subtree paints. Children carry offsets
234
+ * relative to their parent, so the walk accumulates them; negative/overflow
235
+ * offsets are included (the union never clips content).
236
+ * @param {object} node
237
+ * @returns {{minX:number,minY:number,maxX:number,maxY:number}|null}
238
+ */
239
+ export const contentBBox = (node) => {
240
+ let box = null
241
+ const acc = (n, ox, oy) => {
242
+ if (!n) return
243
+ if (selfPaints(n)) {
244
+ const x1 = ox + (n.width || 0)
245
+ const y1 = oy + (n.height || 0)
246
+ if (box === null) box = { minX: ox, minY: oy, maxX: x1, maxY: y1 }
247
+ else {
248
+ if (ox < box.minX) box.minX = ox
249
+ if (oy < box.minY) box.minY = oy
250
+ if (x1 > box.maxX) box.maxX = x1
251
+ if (y1 > box.maxY) box.maxY = y1
252
+ }
253
+ }
254
+ for (const c of n.children || []) acc(c, ox + (c.x || 0), oy + (c.y || 0))
255
+ }
256
+ acc(node, 0, 0)
257
+ return box
258
+ }
259
+
260
+ /**
261
+ * Trim a captured root to its meaningful component:
262
+ * 1) descend through pure pass-through wrappers (a single content-bearing,
263
+ * non-TEXT child, and no paint of their own),
264
+ * 2) crop the kept node to its content bounding box and normalize direct-child
265
+ * offsets so content sits at the origin.
266
+ * Mutates the kept node's geometry in step 2 and returns it. See
267
+ * sorb-capture-trim-spec.md §3.
268
+ * @param {object} root LayerNode
269
+ * @returns {object} the tightened node
270
+ */
271
+ export const tightenRoot = (root) => {
272
+ if (!root) return root
273
+ let node = root
274
+ // 1) descend through pure pass-through wrappers
275
+ while (true) {
276
+ const selfVisual = (node.fills && node.fills[0]) || (node.strokes && node.strokes[0])
277
+ if (selfVisual) break // node paints something → keep it
278
+ const kids = (node.children || []).filter(hasContent)
279
+ // unwrap a sole content-bearing child UNLESS it is TEXT (the wrapper carries
280
+ // the component's frame; don't descend into raw text). Multiple content kids
281
+ // (e.g. a variant row) → stop here.
282
+ if (kids.length === 1 && kids[0].type !== 'TEXT') {
283
+ node = kids[0]
284
+ continue
285
+ }
286
+ break
287
+ }
288
+ // 2) crop node to the bbox of its content (local coords)
289
+ const bbox = contentBBox(node)
290
+ if (bbox === null) return node // nothing drawn → leave as-is
291
+ const dx = bbox.minX
292
+ const dy = bbox.minY
293
+ for (const child of node.children || []) {
294
+ child.x = (child.x || 0) - dx
295
+ child.y = (child.y || 0) - dy
296
+ }
297
+ node.x = 0
298
+ node.y = 0
299
+ node.width = bbox.maxX - bbox.minX
300
+ node.height = bbox.maxY - bbox.minY
301
+ return node
302
+ }
303
+
204
304
  // Install the walker on `window` so it can be called from a Playwright
205
305
  // page.evaluate(() => window.__sorbCapture(...)) after this module is
206
306
  // bundled and injected via addInitScript. No-op in Node tests.
@@ -0,0 +1,142 @@
1
+ // Unit tests for the capture root-trim helpers (sorb-capture-trim-spec.md §6).
2
+ // Pure geometry — no Playwright/DOM needed. Run: `node --test src/capture.test.js`.
3
+
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { hasContent, contentBBox, tightenRoot } from './capture.js'
7
+
8
+ // ─── tiny LayerNode factories ───────────────────────────────────────────────
9
+ const SOLID = [{ type: 'SOLID', r: 0.1, g: 0.3, b: 0.9 }]
10
+
11
+ /** A FRAME. Pass `{ fill: true }` to make it self-painting. */
12
+ const frame = (x, y, w, h, opts = {}, children = []) => ({
13
+ type: 'FRAME',
14
+ name: opts.name || 'div',
15
+ x, y, width: w, height: h,
16
+ fills: opts.fill ? SOLID : [],
17
+ strokes: opts.stroke ? SOLID : [],
18
+ children,
19
+ })
20
+
21
+ const text = (x, y, w, h, value = 'Primary') => ({
22
+ type: 'TEXT', name: value, x, y, width: w, height: h, fills: SOLID, children: [],
23
+ })
24
+
25
+ // ─── hasContent ─────────────────────────────────────────────────────────────
26
+ test('hasContent: TEXT, painted frame, and nested content are content-bearing', () => {
27
+ assert.equal(hasContent(text(0, 0, 10, 10)), true)
28
+ assert.equal(hasContent(frame(0, 0, 10, 10, { fill: true })), true)
29
+ assert.equal(hasContent(frame(0, 0, 10, 10, { stroke: true })), true)
30
+ // transparent wrapper around a painted child → content-bearing via descendant
31
+ assert.equal(hasContent(frame(0, 0, 10, 10, {}, [frame(0, 0, 5, 5, { fill: true })])), true)
32
+ // empty transparent wrapper → not content-bearing
33
+ assert.equal(hasContent(frame(0, 0, 10, 10, {}, [frame(0, 0, 5, 5)])), false)
34
+ assert.equal(hasContent(null), false)
35
+ })
36
+
37
+ // ─── contentBBox ────────────────────────────────────────────────────────────
38
+ test('contentBBox: union of painted/text rects in local coords; null when nothing paints', () => {
39
+ // a painted button with an inset text label
40
+ const button = frame(0, 0, 82, 38, { fill: true }, [text(16, 10, 50, 18)])
41
+ assert.deepEqual(contentBBox(button), { minX: 0, minY: 0, maxX: 82, maxY: 38 })
42
+
43
+ // transparent wrapper: bbox bounds only the painted descendants (accumulated offset)
44
+ const wrapped = frame(0, 0, 200, 100, {}, [frame(20, 30, 40, 20, { fill: true })])
45
+ assert.deepEqual(contentBBox(wrapped), { minX: 20, minY: 30, maxX: 60, maxY: 50 })
46
+
47
+ // nothing drawn → null
48
+ assert.equal(contentBBox(frame(0, 0, 100, 100, {}, [frame(0, 0, 10, 10)])), null)
49
+ })
50
+
51
+ test('contentBBox: negative/overflow offsets are included (never clips content)', () => {
52
+ const n = frame(0, 0, 100, 100, {}, [
53
+ frame(-5, -8, 10, 10, { fill: true }),
54
+ frame(90, 95, 20, 20, { fill: true }),
55
+ ])
56
+ assert.deepEqual(contentBBox(n), { minX: -5, minY: -8, maxX: 110, maxY: 115 })
57
+ })
58
+
59
+ // ─── tightenRoot: the three canonical shapes (spec §6) ──────────────────────
60
+ test('tightenRoot: single component — descends wrappers, crops to the button (82×38 at origin)', () => {
61
+ // story 1248×86 → wrapper → wrapper → button 82×38 @(24,24) → text
62
+ const tree = frame(0, 0, 1248, 86, {}, [
63
+ frame(0, 0, 1248, 86, {}, [
64
+ frame(0, 0, 1248, 86, {}, [
65
+ frame(24, 24, 82, 38, { fill: true, name: 'button' }, [text(16, 10, 50, 18)]),
66
+ ]),
67
+ ]),
68
+ ])
69
+ const out = tightenRoot(tree)
70
+ assert.equal(out.name, 'button')
71
+ assert.equal(out.x, 0)
72
+ assert.equal(out.y, 0)
73
+ assert.equal(out.width, 82)
74
+ assert.equal(out.height, 38)
75
+ // the text child is preserved (annotation-safe) and unshifted (dx=dy=0)
76
+ assert.equal(out.children.length, 1)
77
+ assert.equal(out.children[0].type, 'TEXT')
78
+ })
79
+
80
+ test('tightenRoot: multi-child row — stops at the row, crops to the variant group (tight, not 1248)', () => {
81
+ // a row of three buttons, left-padded by 10
82
+ const row = frame(0, 0, 1248, 40, { name: 'row' }, [
83
+ frame(10, 0, 80, 40, { fill: true }, [text(8, 10, 40, 18)]),
84
+ frame(110, 0, 90, 40, { fill: true }, [text(8, 10, 50, 18)]),
85
+ frame(210, 0, 70, 40, { fill: true }, [text(8, 10, 40, 18)]),
86
+ ])
87
+ const tree = frame(0, 0, 1248, 40, {}, [row])
88
+ const out = tightenRoot(tree)
89
+ assert.equal(out.name, 'row')
90
+ assert.equal(out.x, 0)
91
+ assert.equal(out.width, 270) // (210+70) - 10 → tight row, not 1248
92
+ assert.equal(out.height, 40)
93
+ assert.equal(out.children.length, 3)
94
+ assert.equal(out.children[0].x, 0) // first button shifted left by dx=10
95
+ assert.equal(out.children[1].x, 100)
96
+ assert.equal(out.children[2].x, 200)
97
+ })
98
+
99
+ test('tightenRoot: visual card — descent stops at the painted surface (kept, not skipped)', () => {
100
+ const card = frame(20, 10, 320, 180, { fill: true, name: 'card' }, [
101
+ frame(16, 16, 200, 24, {}, [text(0, 0, 180, 20, 'Title')]),
102
+ ])
103
+ const tree = frame(0, 0, 1248, 220, {}, [card])
104
+ const out = tightenRoot(tree)
105
+ assert.equal(out.name, 'card')
106
+ assert.equal(out.width, 320) // card surface preserved
107
+ assert.equal(out.height, 180)
108
+ assert.equal(out.x, 0)
109
+ })
110
+
111
+ // ─── tightenRoot: edge cases (spec §5) ──────────────────────────────────────
112
+ test('tightenRoot: sole child is TEXT — does not descend into raw text; crops the wrapper to it', () => {
113
+ const tree = frame(0, 0, 200, 50, { name: 'label-wrap' }, [text(5, 5, 40, 16, 'Hi')])
114
+ const out = tightenRoot(tree)
115
+ assert.equal(out.name, 'label-wrap') // kept the wrapper, not the TEXT
116
+ assert.equal(out.width, 40)
117
+ assert.equal(out.height, 16)
118
+ assert.equal(out.children[0].x, 0) // text normalized to origin
119
+ assert.equal(out.children[0].y, 0)
120
+ })
121
+
122
+ test('tightenRoot: nothing drawn — returns the tree untouched, no crash', () => {
123
+ const tree = frame(0, 0, 100, 100, {}, [frame(0, 0, 50, 50)])
124
+ const out = tightenRoot(tree)
125
+ assert.equal(out, tree) // unchanged reference
126
+ assert.equal(out.width, 100)
127
+ })
128
+
129
+ test('tightenRoot: wrapper with its OWN bound fill stops descent (no lost token surface)', () => {
130
+ // an Alert/Card whose background is token-bound: selfVisual → keep it
131
+ const alert = frame(0, 0, 600, 60, { fill: true, name: 'alert' }, [
132
+ frame(0, 0, 600, 60, {}, [text(12, 20, 200, 18, 'Heads up')]),
133
+ ])
134
+ const tree = frame(0, 0, 1248, 60, {}, [alert])
135
+ const out = tightenRoot(tree)
136
+ assert.equal(out.name, 'alert')
137
+ assert.equal(out.width, 600)
138
+ })
139
+
140
+ test('tightenRoot: null/empty input is safe', () => {
141
+ assert.equal(tightenRoot(null), null)
142
+ })
package/src/captureCli.js CHANGED
@@ -7,6 +7,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
7
7
  import { createHash } from 'crypto'
8
8
  import { dirname, resolve, basename, extname } from 'path'
9
9
  import { build } from 'esbuild'
10
+ import { tightenRoot } from './capture.js'
10
11
  import { buildTokenIndex, annotateTree } from './annotateTokens.js'
11
12
 
12
13
  // Playwright is an OPTIONAL peer dep — only `capture` needs it, and it pulls a
@@ -26,6 +27,27 @@ const loadChromium = async () => {
26
27
  }
27
28
  }
28
29
 
30
+ // The `playwright` PACKAGE can be installed while its Chromium BROWSER binary is
31
+ // not (that's a separate `npx playwright install chromium` step). Launching then
32
+ // throws a raw "Executable doesn't exist" error — turn it into the same
33
+ // actionable guidance the missing-package path already gives.
34
+ export const launchChromium = async (chromium) => {
35
+ try {
36
+ return await chromium.launch()
37
+ } catch (e) {
38
+ const msg = e && e.message ? e.message : String(e)
39
+ if (/Executable doesn't exist|playwright install|browserType\.launch/i.test(msg)) {
40
+ console.error(
41
+ '✗ `sorb-seed capture` found Playwright but its Chromium browser is not installed.\n' +
42
+ ' Install the browser where you run capture:\n' +
43
+ ' npx playwright install chromium',
44
+ )
45
+ process.exit(1)
46
+ }
47
+ throw e
48
+ }
49
+ }
50
+
29
51
  const cwd = process.cwd()
30
52
 
31
53
  const loadConfig = () => {
@@ -126,7 +148,7 @@ export const runCapture = async (opts) => {
126
148
  // 2. Browser setup + walker injection
127
149
  const chromium = await loadChromium()
128
150
  const walker = await buildWalkerBundle()
129
- const browser = await chromium.launch()
151
+ const browser = await launchChromium(chromium)
130
152
  const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } })
131
153
  await ctx.addInitScript({ content: walker })
132
154
 
@@ -159,7 +181,12 @@ export const runCapture = async (opts) => {
159
181
  await page.close()
160
182
  continue
161
183
  }
162
- const tree = annotateTree(rawTree, index)
184
+ // Trim the story container down to the meaningful component BEFORE
185
+ // annotation/storage so insert + preview get a tight, token-bound node
186
+ // (sorb-capture-trim-spec.md). Annotation runs on the kept subtree, so
187
+ // token bindings are unaffected.
188
+ const tightened = tightenRoot(rawTree)
189
+ const tree = annotateTree(tightened, index)
163
190
  const hash = 'sha256:' + sha256(JSON.stringify(tree))
164
191
 
165
192
  // --changed: reuse the previous artifact if hash matches