@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.
- package/LICENSE +201 -0
- package/README.md +15 -5
- package/package.json +6 -3
- package/src/annotateTokens.js +161 -19
- package/src/annotateTokens.test.js +208 -0
- package/src/capture.js +100 -0
- package/src/capture.test.js +142 -0
- package/src/captureCli.js +29 -2
- package/src/cli.js +185 -3
- package/src/cli.test.js +51 -0
- package/src/render/cli.js +82 -0
- package/src/render/cli.test.js +47 -0
- package/src/render/diffCache.js +152 -0
- package/src/render/diffCache.test.js +110 -0
- package/src/render/pagePool.js +82 -0
- package/src/render/pagePool.test.js +23 -0
- package/src/render/tokenInject.js +53 -0
- package/src/render/tokenInject.test.js +59 -0
- package/src/render/walkerBundle.js +24 -0
- package/src/render/walkerBundle.test.js +12 -0
- package/src/render/worker.js +193 -0
- package/src/render/worker.test.js +233 -0
- package/src/variants.js +279 -0
- package/src/variants.test.js +129 -0
|
@@ -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
|
|
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
|
-
|
|
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
|