@sorb/seed 0.1.0-canary.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/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # @sorb/seed
2
+
3
+ Storybook → Figma capture for Sorb. This package holds the **heavy**
4
+ pieces (esbuild now; Playwright later) so the bridge (`@sorb/juice`)
5
+ and `@sorb/leaf` stay lean.
6
+
7
+ The full design lives in the team's internal spec (kept out of the repo).
8
+
9
+ ## Install & link the CLI
10
+
11
+ This package is **private / not published to npm yet**, so there's no
12
+ `npm i @sorb/seed`. To get the `sorb-seed` command working:
13
+
14
+ ```bash
15
+ # 1. install this package's deps (from this directory)
16
+ cd packages/seed
17
+ npm install # pulls esbuild (Playwright is optional — see capture)
18
+
19
+ # 2. expose the `sorb-seed` bin on your PATH
20
+ npm link # creates a global symlink to bin → src/cli.js
21
+ ```
22
+
23
+ `sorb-seed` is now runnable from anywhere. To remove the global symlink
24
+ later: `npm unlink -g @sorb/seed` (or `npm rm -g @sorb/seed`).
25
+
26
+ **Prefer not to touch your global PATH?** Skip `npm link` and invoke the source
27
+ directly from the consuming app:
28
+
29
+ ```bash
30
+ node /abs/path/to/sorb/packages/seed/src/cli.js resolve
31
+ ```
32
+
33
+ > **Where you run it matters.** `sorb-seed` reads `sorb.config.json`,
34
+ > `sd.config.js`, and `tokens/` from the **current working directory** — i.e.
35
+ > your *app* (e.g. `example/`), **not** this package directory. Run the commands
36
+ > below from the app you're capturing, after `npm link`ing here once.
37
+
38
+ ## Status
39
+
40
+ Early — not yet published (`private`). Implemented so far:
41
+
42
+ - **`sorb-seed resolve`** — a thin wrapper around **Style Dictionary**. The
43
+ DTCG token sets (`tokens/{primitive,semantic,component}.json`) are the source
44
+ of truth; SD's `sorb/resolved-map` format emits `.sorb/resolved.json` —
45
+ one entry per token: `[{ id, cssVar, value, tier, type }]` where
46
+ `tier ∈ {primitive, semantic, component}`. Reads `sorb.config.json`
47
+ (`styleDictionaryConfig`, default `sd.config.js`). The bridge (`sorb dev`)
48
+ serves this at `GET /tokens/resolved`; the plugin's **Sync Variables** button
49
+ and `capture`'s annotator both consume it. (This retired the old
50
+ esbuild-bundle-and-eval theme resolver.)
51
+
52
+ ```bash
53
+ sorb-seed resolve # → runs style-dictionary build → .sorb/resolved.json
54
+ ```
55
+
56
+ - **`sorb-seed capture`** (`src/captureCli.js`) — Playwright runner that
57
+ visits every story in your running Storybook, injects the walker (below),
58
+ captures the rendered root, annotates tokens against `.sorb/resolved.json`,
59
+ and writes:
60
+ - one **`<Component>.sorb.json`** *next to each story file* (containing
61
+ all of that component's stories), and
62
+ - **`.sorb/index.json`** — a story-id → artifact map (with content hashes
63
+ for `--changed`).
64
+
65
+ Playwright is an **optional peer dependency** — it (and its ~150 MB browser)
66
+ is only needed for `capture`, never for `resolve` or a plain install.
67
+
68
+ The URLs below align with the **sorb-demo** services (`npm run demo`):
69
+
70
+ | Service | URL |
71
+ |---|---|
72
+ | App (Vite) | `http://localhost:5173` |
73
+ | Bridge (`sorb dev`) | `http://localhost:7777` |
74
+ | Storybook | `http://localhost:6006` |
75
+
76
+ ```bash
77
+ # one-time, only if you'll run capture:
78
+ npm install playwright # its postinstall fetches Chromium automatically
79
+ # (if browsers were skipped: npx playwright install chromium)
80
+
81
+ # capture against the demo's Storybook (set once in sorb.config.json)
82
+ sorb-seed capture # uses seed.storybookUrl
83
+ sorb-seed capture --only=Button.stories # filter by importPath/title/id
84
+ sorb-seed capture --changed # skip unchanged stories
85
+ sorb-seed capture --storybook-url=http://localhost:6006 # override on the fly
86
+ ```
87
+
88
+ Set `seed.storybookUrl` in `sorb.config.json` so you don't need the flag:
89
+
90
+ ```jsonc
91
+ {
92
+ "seed": { "storybookUrl": "http://localhost:6006" }
93
+ }
94
+ ```
95
+
96
+ The captured artifacts are then served by the bridge at
97
+ `GET http://localhost:7777/artifacts` (the index) and
98
+ `GET http://localhost:7777/artifact?id=<storyId>` (one artifact, looked up
99
+ by id — never a raw filesystem path). The Figma plugin's **Storybook** tab
100
+ fetches from these endpoints to list and insert captured components.
101
+
102
+ - **`captureRoot(el)`** (`src/capture.js`) — in-page DOM walker (our own
103
+ capture engine, no `htmlToFigma` dependency). Maps element →
104
+ FRAME/RECTANGLE/TEXT `LayerNode` with fills, strokes, corner radius, single
105
+ box-shadow, text (family/weight/size/line-height/letter-spacing/align/color),
106
+ flex → auto-layout + padding, and geometry relative to each parent. Designed
107
+ to run via Playwright's `page.evaluate`; pure helpers (color/dim/shadow
108
+ parsing) unit-tested. Scope (v1) supports the design-system primitive case;
109
+ gradients/grid/transform/pseudo-elements are deferred.
110
+ - **`annotateTree(node, index)`** (`src/annotateTokens.js`) — walks a captured
111
+ tree, attaches a `sorb.tokens` / `sorb.candidates` side-channel to each
112
+ node whose bindable values (fill, stroke, corner radius, effect color) match
113
+ the resolved bindable map. Idempotent; preserves raw values for the plugin
114
+ materializer.
115
+
116
+ Validated end-to-end against the sorb-demo resolved map: a Button DOM →
117
+ `captureRoot` → `annotateTree` binds `fill` → `button.primary.bg.default`,
118
+ `stroke` → `button.primary.border.default`, `cornerRadius` → `button.radius`,
119
+ text fill → `button.primary.text.default`, using tier + property-affinity
120
+ ranking (component > semantic > primitive).
121
+
122
+ Planned: the **plugin materializer** (turns each `LayerNode` into a Figma
123
+ component bound to Variables via `setBoundVariable`); pseudo-elements and
124
+ forced interaction states; component-set assembly from per-story captures.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@sorb/seed",
3
+ "version": "0.1.0-canary.0",
4
+ "description": "Storybook → Figma capture for Sorb: headless capture + the resolved bindable token map. Heavy deps (esbuild, later Playwright) live here so the bridge stays lean.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "sorb",
8
+ "design-tokens",
9
+ "figma",
10
+ "storybook",
11
+ "tokens"
12
+ ],
13
+ "homepage": "https://github.com/metatoy/sorb-seed#readme",
14
+ "bugs": "https://github.com/metatoy/sorb-seed/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/metatoy/sorb-seed.git"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "type": "module",
23
+ "main": "src/index.js",
24
+ "bin": {
25
+ "sorb-seed": "src/cli.js"
26
+ },
27
+ "files": [
28
+ "src"
29
+ ],
30
+ "dependencies": {
31
+ "@sorb/core": "^0.1.0",
32
+ "esbuild": "^0.21.0"
33
+ },
34
+ "peerDependencies": {
35
+ "playwright": "^1.60.0"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "playwright": {
39
+ "optional": true
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,118 @@
1
+ import { TIER_RANK } from '@sorb/core'
2
+
3
+ // Token annotation for captured layer values.
4
+ //
5
+ // Exact, normalized matching against the resolved bindable token map
6
+ // (Style Dictionary's `sorb/resolved-map`: { id, cssVar, value, tier, type }):
7
+ // colors → canonical #rrggbbaa (lowercase); dimensions → px number.
8
+ // On collision, best-guess prefers the most specific tier
9
+ // (component > semantic > primitive), then resolved order; ALL matches are kept
10
+ // as `candidates` so the plugin can offer a switch.
11
+ // (Phase 4 adds property affinity — fill→bg, stroke→border, etc.)
12
+
13
+ const expandHex = (h) => {
14
+ h = h.toLowerCase()
15
+ if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('')
16
+ if (h.length === 6) h += 'ff'
17
+ return '#' + h
18
+ }
19
+ const toHex2 = (n) =>
20
+ Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0')
21
+ const alphaHex = (a) => toHex2(Math.max(0, Math.min(1, a)) * 255)
22
+
23
+ /** Normalize any CSS color to canonical `#rrggbbaa`, or null if not a color. */
24
+ export const normalizeColor = (value) => {
25
+ if (value == null) return null
26
+ const s = String(value).trim().toLowerCase()
27
+ if (s === 'transparent') return '#00000000'
28
+ let m = s.match(/^#([0-9a-f]{3,8})$/)
29
+ if (m) return expandHex(m[1])
30
+ m = s.match(/^rgba?\(\s*([^)]+)\)$/)
31
+ if (m) {
32
+ const p = m[1].split(',').map((x) => x.trim())
33
+ if (p.length < 3) return null
34
+ const a = p[3] !== undefined ? parseFloat(p[3]) : 1
35
+ return '#' + toHex2(+p[0]) + toHex2(+p[1]) + toHex2(+p[2]) + alphaHex(a)
36
+ }
37
+ return null
38
+ }
39
+
40
+ /** Normalize a CSS length to a px number, or null. */
41
+ export const normalizeDimension = (value) => {
42
+ if (value == null) return null
43
+ const m = String(value).trim().match(/^(-?\d+(?:\.\d+)?)(?:px)?$/)
44
+ return m ? parseFloat(m[1]) : null
45
+ }
46
+
47
+ /** Build value→[token] indexes (colors, dims) from the resolved bindable map. */
48
+ export const buildTokenIndex = (resolved) => {
49
+ const colors = new Map()
50
+ const dims = new Map()
51
+ const add = (map, key, t) => {
52
+ if (key == null) return
53
+ if (!map.has(key)) map.set(key, [])
54
+ map.get(key).push(t)
55
+ }
56
+ for (const t of resolved) {
57
+ const c = normalizeColor(t.value)
58
+ if (c) add(colors, c, t)
59
+ else add(dims, normalizeDimension(t.value), t)
60
+ }
61
+ return { colors, dims }
62
+ }
63
+
64
+ // Binding = property affinity first, then tier precedence.
65
+ //
66
+ // Tier rank alone is ambiguous: one color value (e.g. #0F65EF) can match a
67
+ // `bg`, a `border`, AND a `text` token. The captured node's *property* tells us
68
+ // the role it plays — a frame fill wants a `bg` token, a stroke a `border`, a
69
+ // text fill a `text`, a corner radius a `radius`. We filter candidates to that
70
+ // role, then break remaining ties by tier (component > semantic > primitive).
71
+ // If no candidate carries the role, fall back to the full set (tier-only).
72
+ // TIER_RANK is the canonical ordering from @sorb/core (shared contract).
73
+
74
+ // role → the path segment a matching token id should contain (`.bg`, `.text`,
75
+ // `.border`, `.radius`). Matched on `.<role>` so `color.bg.surface`,
76
+ // `button.primary.bg.default`, and `button.radius` all qualify.
77
+ const byTier = (a, b) => (TIER_RANK[a.tier] ?? 9) - (TIER_RANK[b.tier] ?? 9)
78
+
79
+ const pick = (cands, role) => {
80
+ if (!cands || !cands.length) return { token: null, candidates: [] }
81
+ const roled = role ? cands.filter((c) => c.id.includes('.' + role)) : []
82
+ const pool = roled.length ? roled : cands
83
+ const best = [...pool].sort(byTier)[0]
84
+ return { token: best.id, candidates: cands.map((c) => c.id) }
85
+ }
86
+
87
+ export const matchColor = (index, value, role) => {
88
+ const c = normalizeColor(value)
89
+ return c ? pick(index.colors.get(c), role) : { token: null, candidates: [] }
90
+ }
91
+
92
+ export const matchDimension = (index, value, role) => {
93
+ const d = normalizeDimension(value)
94
+ return d == null ? { token: null, candidates: [] } : pick(index.dims.get(d), role)
95
+ }
96
+
97
+ // Walk a captured LayerNode tree and attach `sorb.tokens` / `.candidates`
98
+ // to each node whose bindable values match a token. Mutates and returns the
99
+ // tree. (The plugin materializer reads these to bind Figma Variables.)
100
+ export const annotateTree = (node, index) => {
101
+ const tokens = {}
102
+ const candidates = {}
103
+ const set = (key, res) => {
104
+ if (res.token) { tokens[key] = res.token; candidates[key] = res.candidates }
105
+ }
106
+ // A fill on a TEXT node is foreground (`text`); on a frame it's `bg`.
107
+ const fillRole = node.type === 'TEXT' ? 'text' : 'bg'
108
+ if (node.fills && node.fills[0]) set('fill', matchColor(index, node.fills[0].raw, fillRole))
109
+ if (node.strokes && node.strokes[0]) set('stroke', matchColor(index, node.strokes[0].raw, 'border'))
110
+ if (typeof node.cornerRadius === 'number') set('cornerRadius', matchDimension(index, node.cornerRadius, 'radius'))
111
+ if (Array.isArray(node.effects)) {
112
+ // No shadow tokens yet → no role, falls back to tier-only.
113
+ node.effects.forEach((e, i) => { if (e.color) set(`effect${i}`, matchColor(index, e.color.raw)) })
114
+ }
115
+ if (Object.keys(tokens).length) node.sorb = { tokens, candidates }
116
+ if (Array.isArray(node.children)) node.children.forEach((c) => annotateTree(c, index))
117
+ return node
118
+ }
package/src/capture.js ADDED
@@ -0,0 +1,209 @@
1
+ // Sorb capture walker — runs IN THE PAGE (headless browser).
2
+ //
3
+ // Turns a DOM element (a Storybook story root) into our own `LayerNode` tree
4
+ // via getComputedStyle + getBoundingClientRect. Values are kept as `raw` CSS
5
+ // strings so the token annotator can match them later; colors are also given
6
+ // in Figma's {r,g,b}+opacity form for the materializer.
7
+ //
8
+ // Scope (v1): FRAME/RECTANGLE/TEXT; solid background fills; uniform border +
9
+ // corner radius; a single box-shadow; text (family/weight/size/line-height/
10
+ // letter-spacing/align/color); flex → auto-layout (+ padding); geometry
11
+ // relative to each parent. Deferred: gradients, bg-images, transforms, grid,
12
+ // pseudo-elements, SVG/img, per-side borders, multiple shadows.
13
+ //
14
+ // The DOM functions only run in a browser; the pure helpers are unit-tested.
15
+
16
+ // ─── pure helpers (no DOM) ───────────────────────────────────────────────────
17
+
18
+ /** "rgb(15, 101, 239)" / "rgba(…,a)" → { color:{r,g,b}, opacity, raw } or null. */
19
+ export const rgbToFigma = (str) => {
20
+ if (str == null) return null
21
+ const s = String(str).trim()
22
+ const m = s.match(/^rgba?\(\s*([^)]+)\)$/i)
23
+ if (!m) return null
24
+ const p = m[1].split(',').map((x) => parseFloat(x.trim()))
25
+ if (p.length < 3 || p.some((n, i) => i < 3 && Number.isNaN(n))) return null
26
+ const a = p[3] === undefined ? 1 : p[3]
27
+ return { color: { r: p[0] / 255, g: p[1] / 255, b: p[2] / 255 }, opacity: a, raw: s }
28
+ }
29
+
30
+ /** "4px" → 4; "0px" → 0; "none"/"normal"/"" → null. */
31
+ export const pxNum = (str) => {
32
+ if (str == null) return null
33
+ const m = String(str).trim().match(/^(-?\d+(?:\.\d+)?)px$/)
34
+ return m ? parseFloat(m[1]) : null
35
+ }
36
+
37
+ const FULLY_TRANSPARENT = (c) => !c || c.opacity === 0
38
+
39
+ /** Parse a single CSS box-shadow into a Figma effect, or null for "none". */
40
+ export const parseShadow = (str) => {
41
+ if (str == null) return null
42
+ const s = String(str).trim()
43
+ if (s === '' || s === 'none') return null
44
+ const inset = /\binset\b/.test(s)
45
+ const colorMatch = s.match(/rgba?\([^)]*\)|#[0-9a-f]{3,8}/i)
46
+ const color = colorMatch ? rgbToFigma(colorMatch[0]) : { color: { r: 0, g: 0, b: 0 }, opacity: 1, raw: 'rgb(0,0,0)' }
47
+ const rest = s.replace(colorMatch ? colorMatch[0] : '', '').replace(/\binset\b/, '')
48
+ const nums = (rest.match(/-?\d+(?:\.\d+)?px/g) || []).map((n) => pxNum(n))
49
+ const [x = 0, y = 0, blur = 0, spread = 0] = nums
50
+ return {
51
+ type: inset ? 'INNER_SHADOW' : 'DROP_SHADOW',
52
+ offset: { x, y },
53
+ radius: blur,
54
+ spread,
55
+ color,
56
+ raw: s,
57
+ }
58
+ }
59
+
60
+ /** CSS font-weight → number (normal=400, bold=700). */
61
+ export const normalizeWeight = (w) => {
62
+ if (w === 'normal' || w == null) return 400
63
+ if (w === 'bold') return 700
64
+ const n = parseInt(w, 10)
65
+ return Number.isNaN(n) ? 400 : n
66
+ }
67
+
68
+ export const mapJustify = (v) =>
69
+ ({ 'flex-start': 'MIN', start: 'MIN', 'flex-end': 'MAX', end: 'MAX',
70
+ center: 'CENTER', 'space-between': 'SPACE_BETWEEN' })[v] || 'MIN'
71
+
72
+ export const mapAlign = (v) =>
73
+ ({ 'flex-start': 'MIN', start: 'MIN', 'flex-end': 'MAX', end: 'MAX',
74
+ center: 'CENTER', baseline: 'MIN', stretch: 'MIN' })[v] || 'MIN'
75
+
76
+ export const mapTextAlign = (v) =>
77
+ ({ left: 'LEFT', right: 'RIGHT', center: 'CENTER', justify: 'JUSTIFIED',
78
+ start: 'LEFT', end: 'RIGHT' })[v] || 'LEFT'
79
+
80
+ const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'HEAD', 'NOSCRIPT', 'BR'])
81
+
82
+ // ─── DOM walker (browser only) ───────────────────────────────────────────────
83
+
84
+ const firstFamily = (fontFamily) =>
85
+ String(fontFamily || '').split(',')[0].trim().replace(/^["']|["']$/g, '')
86
+
87
+ const lineHeightOf = (cs) => {
88
+ if (cs.lineHeight === 'normal') return { unit: 'AUTO' }
89
+ const px = pxNum(cs.lineHeight)
90
+ return px == null ? { unit: 'AUTO' } : { unit: 'PIXELS', value: px }
91
+ }
92
+
93
+ const cornerRadiusOf = (cs) => {
94
+ const tl = pxNum(cs.borderTopLeftRadius) || 0
95
+ const tr = pxNum(cs.borderTopRightRadius) || 0
96
+ const br = pxNum(cs.borderBottomRightRadius) || 0
97
+ const bl = pxNum(cs.borderBottomLeftRadius) || 0
98
+ return tl === tr && tr === br && br === bl ? tl : [tl, tr, br, bl]
99
+ }
100
+
101
+ const isHidden = (cs) =>
102
+ cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse'
103
+
104
+ const textLayer = (textNode, cs, parentRect) => {
105
+ const value = textNode.nodeValue.replace(/\s+/g, ' ').trim()
106
+ if (!value) return null
107
+ const range = document.createRange()
108
+ range.selectNodeContents(textNode)
109
+ const r = range.getBoundingClientRect()
110
+ const fill = rgbToFigma(cs.color)
111
+ return {
112
+ type: 'TEXT',
113
+ name: value.slice(0, 40),
114
+ x: r.left - parentRect.left,
115
+ y: r.top - parentRect.top,
116
+ width: r.width,
117
+ height: r.height,
118
+ characters: value,
119
+ fontFamily: firstFamily(cs.fontFamily),
120
+ fontWeight: normalizeWeight(cs.fontWeight),
121
+ italic: cs.fontStyle === 'italic',
122
+ fontSize: pxNum(cs.fontSize) || 16,
123
+ lineHeight: lineHeightOf(cs),
124
+ letterSpacing: pxNum(cs.letterSpacing) || 0,
125
+ textAlign: mapTextAlign(cs.textAlign),
126
+ fills: FULLY_TRANSPARENT(fill) ? [] : [{ type: 'SOLID', ...fill }],
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Capture one element (and its subtree) into a LayerNode.
132
+ * @param {Element} el
133
+ * @param {DOMRect} parentRect rect to make this node's geometry relative to
134
+ * @returns {object|null} LayerNode, or null if skipped
135
+ */
136
+ export const captureNode = (el, parentRect) => {
137
+ if (SKIP_TAGS.has(el.tagName)) return null
138
+ const cs = getComputedStyle(el)
139
+ if (isHidden(cs)) return null
140
+ const rect = el.getBoundingClientRect()
141
+
142
+ const bg = rgbToFigma(cs.backgroundColor)
143
+ const fills = FULLY_TRANSPARENT(bg) ? [] : [{ type: 'SOLID', ...bg }]
144
+
145
+ const borderW = pxNum(cs.borderTopWidth) || 0
146
+ const borderC = rgbToFigma(cs.borderTopColor)
147
+ const hasStroke = borderW > 0 && cs.borderTopStyle !== 'none' && !FULLY_TRANSPARENT(borderC)
148
+ const strokes = hasStroke ? [{ type: 'SOLID', ...borderC }] : []
149
+
150
+ const shadow = parseShadow(cs.boxShadow)
151
+
152
+ // drop entirely invisible, zero-area, contentless nodes
153
+ const area = rect.width * rect.height
154
+ if (area === 0 && !fills.length && !strokes.length) return null
155
+
156
+ const display = cs.display
157
+ const isFlex = display === 'flex' || display === 'inline-flex'
158
+ const layout = isFlex
159
+ ? {
160
+ mode: cs.flexDirection.startsWith('column') ? 'VERTICAL' : 'HORIZONTAL',
161
+ primaryAxisAlign: mapJustify(cs.justifyContent),
162
+ counterAxisAlign: mapAlign(cs.alignItems),
163
+ itemSpacing: pxNum(cs.columnGap) || pxNum(cs.gap) || 0,
164
+ paddingTop: pxNum(cs.paddingTop) || 0,
165
+ paddingRight: pxNum(cs.paddingRight) || 0,
166
+ paddingBottom: pxNum(cs.paddingBottom) || 0,
167
+ paddingLeft: pxNum(cs.paddingLeft) || 0,
168
+ }
169
+ : { mode: 'NONE' }
170
+
171
+ const node = {
172
+ type: 'FRAME',
173
+ name: el.tagName.toLowerCase(),
174
+ x: rect.left - parentRect.left,
175
+ y: rect.top - parentRect.top,
176
+ width: rect.width,
177
+ height: rect.height,
178
+ opacity: cs.opacity === '' ? 1 : parseFloat(cs.opacity),
179
+ clipsContent: cs.overflow === 'hidden' || cs.overflowX === 'hidden',
180
+ fills,
181
+ strokes,
182
+ strokeWeight: hasStroke ? borderW : 0,
183
+ cornerRadius: cornerRadiusOf(cs),
184
+ effects: shadow ? [shadow] : [],
185
+ layout,
186
+ children: [],
187
+ }
188
+
189
+ for (const child of el.childNodes) {
190
+ if (child.nodeType === 3 /* TEXT_NODE */) {
191
+ const t = textLayer(child, cs, rect)
192
+ if (t) node.children.push(t)
193
+ } else if (child.nodeType === 1 /* ELEMENT_NODE */) {
194
+ const c = captureNode(child, rect)
195
+ if (c) node.children.push(c)
196
+ }
197
+ }
198
+ return node
199
+ }
200
+
201
+ /** Entry point: capture a root element with itself at the origin (0,0). */
202
+ export const captureRoot = (el) => captureNode(el, el.getBoundingClientRect())
203
+
204
+ // Install the walker on `window` so it can be called from a Playwright
205
+ // page.evaluate(() => window.__sorbCapture(...)) after this module is
206
+ // bundled and injected via addInitScript. No-op in Node tests.
207
+ if (typeof window !== 'undefined') {
208
+ window.__sorbCapture = (el) => captureRoot(el || document.querySelector('#storybook-root'))
209
+ }
@@ -0,0 +1,256 @@
1
+ // `sorb-seed capture` — Playwright runner that visits each Storybook story,
2
+ // injects our in-page walker, captures the rendered root, annotates tokens
3
+ // against the resolved bindable map, and writes per-component artifacts +
4
+ // a top-level index. See spec → Capture engine.
5
+
6
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
7
+ import { createHash } from 'crypto'
8
+ import { dirname, resolve, basename, extname } from 'path'
9
+ import { build } from 'esbuild'
10
+ import { buildTokenIndex, annotateTree } from './annotateTokens.js'
11
+
12
+ // Playwright is an OPTIONAL peer dep — only `capture` needs it, and it pulls a
13
+ // ~150 MB browser. Lazy-load it so plain installs and `resolve` stay lean.
14
+ const loadChromium = async () => {
15
+ try {
16
+ const { chromium } = await import('playwright')
17
+ return chromium
18
+ } catch {
19
+ console.error(
20
+ '✗ `sorb-seed capture` needs Playwright (it is an optional peer dep).\n' +
21
+ ' Install it where you run capture:\n' +
22
+ ' npm install playwright # its postinstall fetches Chromium\n' +
23
+ ' (or: npm install playwright && npx playwright install chromium)',
24
+ )
25
+ process.exit(1)
26
+ }
27
+ }
28
+
29
+ const cwd = process.cwd()
30
+
31
+ const loadConfig = () => {
32
+ const p = resolve(cwd, 'sorb.config.json')
33
+ if (!existsSync(p)) {
34
+ console.error('✗ No sorb.config.json in', cwd)
35
+ process.exit(1)
36
+ }
37
+ return JSON.parse(readFileSync(p, 'utf-8'))
38
+ }
39
+
40
+ const loadResolved = () => {
41
+ const p = resolve(cwd, '.sorb/resolved.json')
42
+ if (!existsSync(p)) {
43
+ console.error('✗ No .sorb/resolved.json — run `sorb-seed resolve` first.')
44
+ process.exit(1)
45
+ }
46
+ const data = JSON.parse(readFileSync(p, 'utf-8'))
47
+ return Array.isArray(data) ? data : data.tokens
48
+ }
49
+
50
+ const sha256 = (s) => createHash('sha256').update(s).digest('hex')
51
+
52
+ // Bundle the walker into a single IIFE string we can addInitScript() into
53
+ // every page. Playwright can't pass functions across the boundary directly,
54
+ // and our walker has cross-file imports → bundling is the clean answer.
55
+ const buildWalkerBundle = async () => {
56
+ const here = dirname(new URL(import.meta.url).pathname)
57
+ const out = await build({
58
+ entryPoints: [resolve(here, 'capture.js')],
59
+ bundle: true,
60
+ format: 'iife',
61
+ platform: 'browser',
62
+ write: false,
63
+ logLevel: 'silent',
64
+ target: 'es2020',
65
+ })
66
+ return out.outputFiles[0].text
67
+ }
68
+
69
+ const isStoryEntry = (e) =>
70
+ e && (e.type === 'story' || (e.type === undefined && e.importPath)) // SB7/8: type:'story'
71
+
72
+ // Group story entries by their component (importPath) and pick the directory
73
+ // of the story file as the artifact output dir (co-located).
74
+ const groupByComponent = (entries) => {
75
+ const groups = new Map()
76
+ for (const e of entries) {
77
+ if (!groups.has(e.importPath)) groups.set(e.importPath, [])
78
+ groups.get(e.importPath).push(e)
79
+ }
80
+ return groups
81
+ }
82
+
83
+ // componentName: "Button" from "./src/.../Button.stories.jsx"
84
+ const componentNameFromImportPath = (importPath) => {
85
+ const file = basename(importPath, extname(importPath)) // "Button.stories"
86
+ return file.replace(/\.stories$/i, '')
87
+ }
88
+
89
+ // sorb.config.json may set seed.storybookUrl. Fall back to localhost.
90
+ const storybookUrlOf = (config) =>
91
+ (config.seed && config.seed.storybookUrl) || 'http://localhost:6006'
92
+
93
+ const filterEntries = (entries, only) => {
94
+ if (!only) return entries
95
+ const re = new RegExp(only.replace(/[*]/g, '.*'), 'i')
96
+ return entries.filter((e) => re.test(e.importPath) || re.test(e.title) || re.test(e.id))
97
+ }
98
+
99
+ export const runCapture = async (opts) => {
100
+ const config = loadConfig()
101
+ const resolved = loadResolved()
102
+ const index = buildTokenIndex(resolved)
103
+ const sbUrl = (opts.storybookUrl || storybookUrlOf(config)).replace(/\/$/, '')
104
+
105
+ // 1. Discover stories
106
+ console.log(`→ Storybook: ${sbUrl}`)
107
+ let sbIndex
108
+ try {
109
+ const res = await fetch(`${sbUrl}/index.json`)
110
+ if (!res.ok) throw new Error('HTTP ' + res.status)
111
+ sbIndex = await res.json()
112
+ } catch (e) {
113
+ console.error('✗ Could not fetch Storybook index:', e.message)
114
+ process.exit(1)
115
+ }
116
+ const entries = filterEntries(
117
+ Object.values(sbIndex.entries || sbIndex.stories || {}).filter(isStoryEntry),
118
+ opts.only,
119
+ )
120
+ if (!entries.length) {
121
+ console.error('✗ No stories matched filter:', opts.only || '(all)')
122
+ process.exit(1)
123
+ }
124
+ console.log(`→ ${entries.length} stories selected`)
125
+
126
+ // 2. Browser setup + walker injection
127
+ const chromium = await loadChromium()
128
+ const walker = await buildWalkerBundle()
129
+ const browser = await chromium.launch()
130
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } })
131
+ await ctx.addInitScript({ content: walker })
132
+
133
+ // 3. Capture each story, group by component
134
+ const captured = new Map() // importPath -> { component, importPath, stories[] }
135
+ const oldIndex = readOldIndex(config)
136
+
137
+ for (const entry of entries) {
138
+ const url = `${sbUrl}/iframe.html?id=${entry.id}&viewMode=story`
139
+ const page = await ctx.newPage()
140
+ try {
141
+ console.log(` · ${entry.id}`)
142
+ await page.goto(url, { waitUntil: 'load' })
143
+ // Wait for Storybook to actually render the story.
144
+ await page
145
+ .waitForFunction(
146
+ () => !!document.querySelector('#storybook-root *'),
147
+ { timeout: 15000 },
148
+ )
149
+ .catch(() => {})
150
+ await page.evaluate(() => document.fonts && document.fonts.ready)
151
+ await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {})
152
+
153
+ const rawTree = await page.evaluate(() => {
154
+ const root = document.querySelector('#storybook-root')
155
+ return root ? window.__sorbCapture(root) : null
156
+ })
157
+ if (!rawTree) {
158
+ console.warn(' ⚠ no #storybook-root content; skipped')
159
+ await page.close()
160
+ continue
161
+ }
162
+ const tree = annotateTree(rawTree, index)
163
+ const hash = 'sha256:' + sha256(JSON.stringify(tree))
164
+
165
+ // --changed: reuse the previous artifact if hash matches
166
+ const prevHash = oldIndex.stories[entry.id]?.hash
167
+ if (opts.changed && prevHash === hash) {
168
+ console.log(' = unchanged')
169
+ await page.close()
170
+ continue
171
+ }
172
+
173
+ if (!captured.has(entry.importPath)) {
174
+ captured.set(entry.importPath, {
175
+ schemaVersion: 1,
176
+ component: componentNameFromImportPath(entry.importPath),
177
+ importPath: entry.importPath,
178
+ capturedAt: new Date().toISOString(),
179
+ stories: [],
180
+ })
181
+ }
182
+ captured.get(entry.importPath).stories.push({
183
+ id: entry.id,
184
+ name: entry.name,
185
+ title: entry.title,
186
+ hash,
187
+ root: tree,
188
+ })
189
+ } catch (e) {
190
+ console.error(' ✗', entry.id, '—', e.message)
191
+ } finally {
192
+ await page.close()
193
+ }
194
+ }
195
+ await browser.close()
196
+
197
+ // 4. Write artifacts next to each story file, then the index
198
+ const indexOut = {
199
+ schemaVersion: 1,
200
+ generatedAt: new Date().toISOString(),
201
+ storybookUrl: sbUrl,
202
+ components: [],
203
+ stories: { ...oldIndex.stories }, // preserves entries we didn't recapture
204
+ }
205
+ for (const [importPath, art] of captured) {
206
+ const absStoryFile = resolve(cwd, importPath)
207
+ const outDir = dirname(absStoryFile)
208
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true })
209
+ const outPath = resolve(outDir, `${art.component}.sorb.json`)
210
+ writeFileSync(outPath, JSON.stringify(art, null, 2) + '\n')
211
+ const rel = relativeFromCwd(outPath)
212
+ console.log(`✓ ${rel} (${art.stories.length} stor${art.stories.length === 1 ? 'y' : 'ies'})`)
213
+ indexOut.components.push({ component: art.component, importPath, artifact: rel })
214
+ for (const s of art.stories) {
215
+ indexOut.stories[s.id] = {
216
+ component: art.component,
217
+ importPath,
218
+ artifact: rel,
219
+ title: s.title,
220
+ name: s.name,
221
+ hash: s.hash,
222
+ }
223
+ }
224
+ }
225
+
226
+ const indexDir = resolve(cwd, '.sorb')
227
+ mkdirSync(indexDir, { recursive: true })
228
+ writeFileSync(
229
+ resolve(indexDir, 'index.json'),
230
+ JSON.stringify(indexOut, null, 2) + '\n',
231
+ )
232
+ console.log('→ .sorb/index.json')
233
+ }
234
+
235
+ const relativeFromCwd = (abs) => {
236
+ const c = cwd.endsWith('/') ? cwd : cwd + '/'
237
+ return abs.startsWith(c) ? abs.slice(c.length) : abs
238
+ }
239
+
240
+ const readOldIndex = (config) => {
241
+ const p = resolve(cwd, '.sorb/index.json')
242
+ if (!existsSync(p)) return { stories: {} }
243
+ try { return JSON.parse(readFileSync(p, 'utf-8')) } catch { return { stories: {} } }
244
+ }
245
+
246
+ // Allow running as `node captureCli.js [--only=...] [--changed]` for testing;
247
+ // the package bin (`sorb-seed capture`) routes here via cli.js.
248
+ if (import.meta.url === `file://${process.argv[1]}`) {
249
+ const opts = {}
250
+ for (const a of process.argv.slice(2)) {
251
+ if (a === '--changed') opts.changed = true
252
+ else if (a.startsWith('--only=')) opts.only = a.slice('--only='.length)
253
+ else if (a.startsWith('--storybook-url=')) opts.storybookUrl = a.slice('--storybook-url='.length)
254
+ }
255
+ runCapture(opts).catch((e) => { console.error(e); process.exit(1) })
256
+ }
package/src/cli.js ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, existsSync } from 'fs'
3
+ import { resolve } from 'path'
4
+ import { execSync } from 'child_process'
5
+
6
+ // sorb-seed — Storybook → Figma capture tooling.
7
+ //
8
+ // `resolve` is now a thin wrapper around Style Dictionary: the DTCG token sets
9
+ // (primitive/semantic/component) are the source of truth, and SD's
10
+ // `sorb/resolved-map` format produces `.sorb/resolved.json` directly —
11
+ // the schema { id, cssVar, value, tier, type }. This retires the old
12
+ // esbuild-bundle-and-eval resolver (and its theme.js scraping). The app build
13
+ // owns SD; the bridge and `capture` are pure consumers of its output.
14
+
15
+ const cwd = process.cwd()
16
+
17
+ const loadConfig = () => {
18
+ const p = resolve(cwd, 'sorb.config.json')
19
+ if (!existsSync(p)) {
20
+ console.error('✗ No sorb.config.json in', cwd)
21
+ process.exit(1)
22
+ }
23
+ return JSON.parse(readFileSync(p, 'utf-8'))
24
+ }
25
+
26
+ const cmd = process.argv[2] || 'resolve'
27
+
28
+ if (cmd === 'resolve') {
29
+ const config = loadConfig()
30
+ const sdConfig = config.styleDictionaryConfig || 'sd.config.js'
31
+ const abs = resolve(cwd, sdConfig)
32
+ if (!existsSync(abs)) {
33
+ console.error(
34
+ '✗ Style Dictionary config not found:', sdConfig,
35
+ '\n Set "styleDictionaryConfig" in sorb.config.json (default: sd.config.js).',
36
+ )
37
+ process.exit(1)
38
+ }
39
+ try {
40
+ console.log('→ Running Style Dictionary:', sdConfig)
41
+ execSync(`npx style-dictionary build --config ${abs}`, { stdio: 'inherit', cwd })
42
+ console.log('✓ Built .sorb/resolved.json (and CSS vars + theme) from DTCG sources')
43
+ } catch (e) {
44
+ console.error('✗ Style Dictionary build failed')
45
+ process.exit(1)
46
+ }
47
+ } else if (cmd === 'capture') {
48
+ const { runCapture } = await import('./captureCli.js')
49
+ const opts = {}
50
+ for (const a of process.argv.slice(3)) {
51
+ if (a === '--changed') opts.changed = true
52
+ else if (a.startsWith('--only=')) opts.only = a.slice('--only='.length)
53
+ else if (a.startsWith('--storybook-url=')) opts.storybookUrl = a.slice('--storybook-url='.length)
54
+ }
55
+ await runCapture(opts)
56
+ } else {
57
+ console.error(`Unknown command: ${cmd}\nUsage: sorb-seed <resolve|capture> [options]`)
58
+ process.exit(1)
59
+ }
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // Public library surface for @sorb/seed.
2
+ //
3
+ // The resolved bindable token map is now produced by Style Dictionary (see
4
+ // `sorb-seed resolve`, a thin SD wrapper), not by scraping a theme module —
5
+ // so the old `resolveBindableTokens` export is gone. What remains useful as a
6
+ // library is the token-annotation layer used by `capture`.
7
+ export { buildTokenIndex, annotateTree, matchColor, matchDimension } from './annotateTokens.js'