kaloko 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +2 -0
  4. package/schemas/revision.v1.json +11 -0
  5. package/schemas/run.v1.json +12 -3
  6. package/schemas/scenario.v1.json +14 -0
  7. package/skills/kaloko/reference/design.md +93 -1
  8. package/skills/kaloko/reference/execution.md +6 -0
  9. package/skills/kaloko/reference/portable-html.md +12 -3
  10. package/skills/kaloko/reference/stacks.md +6 -0
  11. package/src/cli.mjs +119 -47
  12. package/src/lib/access.mjs +1 -1
  13. package/src/lib/apps/drivers.mjs +1 -1
  14. package/src/lib/apps/webview.mjs +3 -3
  15. package/src/lib/build.mjs +9 -46
  16. package/src/lib/capture.mjs +1 -1
  17. package/src/lib/changelog.mjs +2 -2
  18. package/src/lib/design-cli.mjs +121 -47
  19. package/src/lib/design-core.mjs +26 -4
  20. package/src/lib/design-export.mjs +28 -8
  21. package/src/lib/design-lint.mjs +45 -11
  22. package/src/lib/design-origins.mjs +86 -0
  23. package/src/lib/design.mjs +112 -8
  24. package/src/lib/dimensions.mjs +66 -12
  25. package/src/lib/doctor.mjs +72 -10
  26. package/src/lib/drift.mjs +1 -1
  27. package/src/lib/evaluate.mjs +7 -3
  28. package/src/lib/figma.mjs +1 -1
  29. package/src/lib/integrations-cli.mjs +3 -3
  30. package/src/lib/login.mjs +1 -1
  31. package/src/lib/mail.mjs +1 -1
  32. package/src/lib/manifest-core.mjs +90 -5
  33. package/src/lib/run.mjs +1 -1
  34. package/src/lib/service.mjs +39 -9
  35. package/src/lib/spec-cli.mjs +7 -4
  36. package/src/lib/stability.mjs +1 -1
  37. package/src/lib/stack.mjs +16 -9
  38. package/src/lib/tokens.mjs +16 -0
  39. package/src/lib/util.mjs +6 -0
  40. package/src/lib/video.mjs +11 -2
  41. package/src/lib/vision.mjs +1 -1
  42. package/src/lib/walk-dimensions.mjs +31 -6
  43. package/viewer/index.html +249 -48
@@ -11,10 +11,12 @@ import { loadTokens } from './drift.mjs'
11
11
  import { layout } from './scenario.mjs'
12
12
  import { launchEphemeral } from './browser.mjs'
13
13
  import { observeConsole } from './console.mjs'
14
- import { contextOptionsOf } from './dimensions.mjs'
15
- import { slimCapture } from './manifest-core.mjs'
14
+ import { contextOptionsOf, stateOf } from './dimensions.mjs'
15
+ import { slimCapture, keyAppliesTo } from './manifest-core.mjs'
16
+ import { modeCss } from './tokens.mjs'
17
+ import { fieldsOf } from './stack.mjs'
16
18
  import { captureKey, sha256Hex, extOf, TEXT_TYPES, RENDERER } from './design-core.mjs'
17
- import { designDir, revisionOf, serveDesign, copyLive, fromCache, toCache, restoreCached, readBase, writeBase, headKey, tokensRef } from './design.mjs'
19
+ import { designDir, revisionOf, serveDesign, copyLive, fromCache, toCache, restoreCached, readBase, writeBase, headKey, tokensRef, statePage } from './design.mjs'
18
20
 
19
21
  const enc = encodeURIComponent
20
22
  const screensOf = (scenario) => scenario.nodes.filter((n) => n.kind !== 'decision' && n.kind !== 'external')
@@ -26,29 +28,50 @@ export async function localRevisions ({ dir, scenario, locales, allLocales, proj
26
28
  for (const locale of locales) {
27
29
  for (const node of screensOf(scenario)) {
28
30
  if (pick && !pick.includes(node.id)) continue
29
- const rev = await revisionOf({ dir, scenario: scenario.id, node: node.id, locale, locales: allLocales, tokens })
31
+ const rev = await revisionOf({ dir, scenario: scenario.id, node: node.id, locale, locales: allLocales, tokens, states: node.states, stack: node.kind === 'stack' ? node : null })
30
32
  if (rev) out[headKey(node.id, locale)] = rev
31
33
  }
32
34
  }
33
35
  return out
34
36
  }
35
37
 
38
+ /**
39
+ * In the page: a token mode's custom properties after the page's own styles, and data-kaloko-mode on <html> for
40
+ * pages that switch themes themselves (#46). `null` removes both.
41
+ */
42
+ function setTokenMode (arg) {
43
+ const old = document.getElementById('kaloko-token-mode')
44
+ if (old) old.remove()
45
+ if (!arg) { document.documentElement.removeAttribute('data-kaloko-mode'); return }
46
+ const style = document.createElement('style')
47
+ style.id = 'kaloko-token-mode'
48
+ style.textContent = arg.css
49
+ ;(document.head || document.documentElement).appendChild(style)
50
+ document.documentElement.setAttribute('data-kaloko-mode', arg.mode)
51
+ }
52
+
36
53
  /**
37
54
  * Walk of a design environment: serves the folder, renders each step and locale in every viewport, and reuses
38
55
  * captures of revisions that were already rendered (content cache). Writes revisions.json into the run.
56
+ * Steps with states (#46) render each state's page (statePage, else the entry with ?state=<name>); runs with token
57
+ * modes render each mode with its tokens as custom properties.
39
58
  */
40
59
  export async function walkDesign ({ config, root, run, scenario, env, pick, locales, viewports, say, noCache = false }) {
41
60
  const dir = designDir(env, root)
42
61
  const revisions = await readJson(join(run.dir, 'revisions.json'), {})
43
62
  const fresh = await localRevisions({ dir, scenario, locales, allLocales: run.meta.scope.locales, project: run.meta.project?.slug, pick })
44
63
  const missing = []
45
- for (const locale of locales) for (const node of screensOf(scenario)) if ((!pick || pick.includes(node.id)) && !fresh[headKey(node.id, locale)]) missing.push(`${node.id} (${locale}): add ${node.id}/index.html to ${relative(process.cwd(), dir)}/`)
64
+ for (const locale of locales) for (const node of screensOf(scenario)) if ((!pick || pick.includes(node.id)) && !fresh[headKey(node.id, locale)]) missing.push(`${node.id} (${locale}): add ${node.kind === 'stack' ? `a folder per variant, ${node.id}/<variant>/index.html,` : `${node.id}/index.html`} to ${relative(process.cwd(), dir)}/`)
46
65
  const server = await serveDesign(dir)
47
66
  const browser = await launchEphemeral(config)
48
67
  const paths = runPaths(run.dir)
49
68
  const mode = env.tokens_mode || ''
50
69
  // the design folder's own set names the values in the step spec (the one its live pages reference)
51
70
  const tokens = await loadTokens({ client: null, config, root, designDir: dir })
71
+ const scope0 = run.meta.scope
72
+ // token modes (#46): the set's values of each mode as CSS, set on the page before it is captured
73
+ const modeSheets = new Map((scope0?.dimensions?.token_modes || []).map((m) => [m, tokens ? modeCss(tokens, m) : '']))
74
+ const stateNotes = new Set()
52
75
  let rendered = 0, reused = 0
53
76
  try {
54
77
  // one page per device profile (batch E): a phone preset renders with touch, its pixel ratio and user agent, so
@@ -61,42 +84,69 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
61
84
  if (!pages.has(k)) { const p = await (await browser.newContext(contextOptionsOf({ engine: 'chromium', emulation }))).newPage(); observeConsole(p); pages.set(k, p) }
62
85
  return pages.get(k)
63
86
  }
64
- for (const [key, rev] of Object.entries(fresh)) {
65
- const node = scenario.nodes.find((n) => n.id === rev.node)
66
- revisions[key] = rev
67
- await copyLive(run.dir, dir, rev)
68
- const live = { revision: rev.id, entry: rev.entry }
87
+ // renders one page (a step, or one item of a stack step) in the viewports whose captures are not cached
88
+ const renderPage = async ({ node, rev, entry: base, vpsIn, item = null }) => {
89
+ const stepId = item ? `${node.id}/items/${item.id}` : node.id
90
+ const live = { revision: rev.id, entry: base }
69
91
  const todo = {}
70
- for (const [vp, size] of Object.entries(viewports)) {
71
- const ck = captureKey(rev.id, vp, mode)
92
+ for (const [vp, size] of Object.entries(vpsIn)) {
93
+ if (!keyAppliesTo(vp, node)) continue // a state this step does not have
94
+ const ck = captureKey(rev.id, vp, mode, item?.id)
72
95
  const cached = noCache ? null : await fromCache({ root, output: config.output, key: ck })
73
- // a capture from before the step spec existed renders again, so Dev mode and `kaloko spec` have it
74
- if (cached && cached.record.criteria_hash === criteriaHash(node) && cached.record.spec) {
75
- const record = await restoreCached({ cached, runDir: run.dir, paths, node: node.id, locale: rev.locale, viewport: vp })
76
- const file = paths.step(node.id, rev.locale, vp, 'capture.json')
96
+ // a capture from before the step spec existed renders again, so Dev mode and `kaloko spec` have it (items have none)
97
+ if (cached && cached.record.criteria_hash === criteriaHash(node) && (item || cached.record.spec)) {
98
+ const record = await restoreCached({ cached, runDir: run.dir, paths, node: stepId, locale: rev.locale, viewport: vp })
99
+ const file = paths.step(stepId, rev.locale, vp, 'capture.json')
77
100
  await writeJson(file, record)
78
- await addCapture(run.dir, { type: 'capture', node: node.id, locale: rev.locale, viewport: vp, file: relative(run.dir, file), at: new Date().toISOString() })
101
+ await addCapture(run.dir, { type: 'capture', node: node.id, ...(item ? { item: item.id } : {}), locale: rev.locale, viewport: vp, file: relative(run.dir, file), at: new Date().toISOString() })
79
102
  reused++
80
103
  } else todo[vp] = size
81
104
  }
82
- // dark captures render <step>/dark/index.html when the step has one; otherwise the page itself in the dark
105
+ // dark captures render <page folder>/dark/index.html when there is one; otherwise the page itself in the dark
83
106
  // scheme (prefers-color-scheme emulated): one page load per entry
84
- const darkEntry = rev.entry.replace(/index\.html$/, 'dark/index.html')
107
+ const darkEntry = base.replace(/index\.html$/, 'dark/index.html')
85
108
  const groups = new Map()
86
109
  for (const [vp, size] of Object.entries(todo)) {
87
- const entry = run.meta.scope?.variants?.[vp]?.color_scheme === 'dark' && rev.files[darkEntry] ? darkEntry : rev.entry
110
+ const variant = run.meta.scope?.variants?.[vp] || {}
111
+ const state = stateOf(vp)
112
+ // a state renders its own page (<page folder>/<state>.html), else the page with ?state=<name>
113
+ let entry = state === 'default' ? base : statePage(base, state, rev.files)
114
+ let query = ''
115
+ if (!entry) {
116
+ entry = base; query = `?state=${encodeURIComponent(state)}`
117
+ const note = `${node.id}${item ? `/${item.id}` : ''} ${state}: no ${base.replace(/index\.html$/, `${state}.html`)}; rendered ${base}${query}`
118
+ if (!stateNotes.has(note)) { stateNotes.add(note); say(` ${note}`) }
119
+ }
120
+ if (state === 'default' && variant.color_scheme === 'dark' && rev.files[darkEntry]) entry = darkEntry
121
+ const tokenMode = modeSheets.size ? (variant.token_mode || scope0.dimensions.token_modes[0]) : null
88
122
  const page = await pageFor(vp)
89
- const g = `${entry}|${[...pages.values()].indexOf(page)}`
90
- if (!groups.has(g)) groups.set(g, { entry, page, vps: {} })
123
+ const g = `${entry}${query}|${tokenMode || ''}|${[...pages.values()].indexOf(page)}`
124
+ if (!groups.has(g)) groups.set(g, { entry, query, tokenMode, page, vps: {} })
91
125
  groups.get(g).vps[vp] = size
92
126
  }
93
- for (const { entry, page, vps } of groups.values()) {
127
+ for (const { entry, query, tokenMode, page, vps } of groups.values()) {
94
128
  const since = new Date().toISOString()
95
- await page.goto(`${server.url}/${entry}`, { waitUntil: 'load' })
96
- const results = await captureStep({ page, node, locale: rev.locale, viewports: vps, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live: entry === rev.entry ? live : { ...live, entry } }, designSummary: true, tokens })
97
- for (const r of results) { await toCache({ root, output: config.output, key: captureKey(rev.id, r.viewport, mode), runDir: run.dir, record: r }); rendered++ }
129
+ await page.goto(`${server.url}/${entry}${query}`, { waitUntil: 'load' })
130
+ if (tokenMode) await page.evaluate(setTokenMode, { mode: tokenMode, css: modeSheets.get(tokenMode) || '' })
131
+ const results = await captureStep({ page, node, locale: rev.locale, viewports: vps, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live: entry !== base ? { ...live, entry } : query ? { ...live, query: query.slice(1) } : live }, designSummary: true, tokens, ...(item ? { item, fields: fieldsOf(node) } : {}) })
132
+ for (const r of results) { await toCache({ root, output: config.output, key: captureKey(rev.id, r.viewport, mode, item?.id), runDir: run.dir, record: r }); rendered++ }
133
+ }
134
+ return Object.keys(todo).length
135
+ }
136
+ for (const [key, rev] of Object.entries(fresh)) {
137
+ const node = scenario.nodes.find((n) => n.id === rev.node)
138
+ revisions[key] = rev
139
+ await copyLive(run.dir, dir, rev)
140
+ if (rev.items) {
141
+ // a stack step (#61): every variant is a page of its own, captured like a page of a stack in an app run
142
+ const vps = stackViewports(node, viewports)
143
+ let todo = 0
144
+ for (const it of rev.items) todo += await renderPage({ node, rev, entry: it.entry, vpsIn: vps, item: { id: it.id, path: it.entry.replace(/index\.html$/, '') } })
145
+ say(`✓ ${node.id} ${rev.locale} revision ${rev.id.slice(0, 8)} ${rev.items.length} variant(s)${todo ? '' : ' (unchanged, reused)'}`)
146
+ continue
98
147
  }
99
- say(`✓ ${node.id} ${rev.locale} revision ${rev.id.slice(0, 8)}${Object.keys(todo).length ? '' : ' (unchanged, reused)'}`)
148
+ const todo = await renderPage({ node, rev, entry: rev.entry, vpsIn: viewports })
149
+ say(`✓ ${node.id} ${rev.locale} revision ${rev.id.slice(0, 8)}${todo ? '' : ' (unchanged, reused)'}`)
100
150
  }
101
151
  } finally {
102
152
  await browser.close()
@@ -108,6 +158,17 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
108
158
  return { rendered, reused, revisions }
109
159
  }
110
160
 
161
+ /**
162
+ * Viewports a stack step's variants are captured in on a design environment (#61): `items.viewports` when the step
163
+ * names some, else every viewport of the walk (a design has a few variants, not hundreds of pages).
164
+ */
165
+ export function stackViewports (node, viewports) {
166
+ const want = node.items?.viewports
167
+ if (!Array.isArray(want) || !want.length) return viewports
168
+ const out = Object.fromEntries(Object.entries(viewports).filter(([k]) => want.includes(k) || want.includes(k.split('.')[0])))
169
+ return Object.keys(out).length ? out : viewports
170
+ }
171
+
111
172
  /**
112
173
  * The scope the draft keeps: locales and capture keys, and (batch E) what the keys mean and the device presets, so
113
174
  * versions on the service get the scheme switcher and device frames. User agents stay local.
@@ -163,6 +224,8 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
163
224
  const remote = await client.request('GET', `/orgs/${org}/scenarios/${sid}/draft?branch=${enc(branch)}`)
164
225
  const remoteHeads = Object.fromEntries((remote.heads || []).map((h) => [headKey(h.node, h.locale, h.state), h.revision]))
165
226
  const remoteCaps = Object.fromEntries((remote.heads || []).map((h) => [headKey(h.node, h.locale, h.state), h.captures || {}]))
227
+ // a stack step's captures are per variant (#61): item → viewport → { renderer, criteria_hash }
228
+ const remoteItemCaps = Object.fromEntries((remote.heads || []).map((h) => [headKey(h.node, h.locale, h.state), h.item_captures || {}]))
166
229
  const snapshot = snapshotOf(scenario, run)
167
230
  const snapshotChanged = !remote.exists || remote.snapshot_hash !== await sha256Hex(JSON.stringify(snapshot))
168
231
  let local = await localRevisions({ dir, scenario, locales, allLocales: locales, project: run.meta.project?.slug })
@@ -214,32 +277,38 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
214
277
  }
215
278
  let result = null
216
279
  // same revision, but its draft captures were rendered by another renderer or for other criteria: send them again
217
- const vpsAll = Object.keys(run.meta.scope.viewports)
218
- const toRefresh = push ? Object.keys(local).filter((key) => local[key].id === remoteHeads[key] && !toPush.includes(key) && vpsAll.some((vp) => { const rc = remoteCaps[key]?.[vp]; const node = scenario.nodes.find((n) => n.id === local[key].node); return !rc || rc.renderer !== RENDERER || rc.criteria_hash !== criteriaHash(node) })) : []
280
+ const nodeOf = (key) => scenario.nodes.find((n) => n.id === local[key].node)
281
+ // what a step's draft captures are: [item, viewport] pairs (item '' for a plain step; every variant of a stack step),
282
+ // without the keys of states (#46) the step does not have
283
+ const slots = (key) => {
284
+ const rev = local[key]
285
+ const vps = Object.keys(rev.items ? stackViewports(nodeOf(key), run.meta.scope.viewports) : run.meta.scope.viewports).filter((k) => keyAppliesTo(k, nodeOf(key)))
286
+ return rev.items ? rev.items.flatMap((it) => vps.map((vp) => [it.id, vp])) : vps.map((vp) => ['', vp])
287
+ }
288
+ const toRefresh = push ? Object.keys(local).filter((key) => local[key].id === remoteHeads[key] && !toPush.includes(key) && slots(key).some(([item, vp]) => { const rc = item ? remoteItemCaps[key]?.[item]?.[vp] : remoteCaps[key]?.[vp]; return !rc || rc.renderer !== RENDERER || rc.criteria_hash !== criteriaHash(nodeOf(key)) })) : []
219
289
  if (push && (toPush.length || toRefresh.length || snapshotChanged)) {
220
- // steps to push need captures of their current revision in every viewport of the run
221
- const vps = Object.keys(run.meta.scope.viewports)
222
- const capturesByKey = async () => new Map(latestCaptures(await readCaptures(run.dir)).map((c) => [`${c.node}|${c.locale}|${c.viewport}`, c]))
290
+ // steps to push need captures of their current revision in every viewport of the run (and state of the step)
291
+ const capturesByKey = async () => new Map(latestCaptures(await readCaptures(run.dir)).map((c) => [`${c.node}|${c.item || ''}|${c.locale}|${c.viewport}`, c]))
223
292
  let byKey = await capturesByKey()
224
293
  const stale = []
225
294
  const sending = [...toPush, ...toRefresh]
226
295
  for (const key of sending) {
227
296
  const [node, locale] = key.split('|')
228
297
  const want = criteriaHash(scenario.nodes.find((n) => n.id === node))
229
- for (const vp of vps) {
230
- const c = byKey.get(`${node}|${locale}|${vp}`)
298
+ for (const [item, vp] of slots(key)) {
299
+ const c = byKey.get(`${node}|${item}|${locale}|${vp}`)
231
300
  const rec = c ? await readJson(join(run.dir, c.file), null) : null
232
- if (!rec || rec.live?.revision !== local[key].id || rec.criteria_hash !== want || !rec.design) stale.push(node)
301
+ if (!rec || rec.live?.revision !== local[key].id || rec.criteria_hash !== want || (!item && !rec.design)) stale.push(node)
233
302
  }
234
303
  }
235
304
  if (stale.length) { await render([...new Set(stale)]); byKey = await capturesByKey() }
236
305
  // every pushed capture carries a valid evaluation of the scenario's criteria (deterministic checks are free)
237
306
  let unevaluated = false
238
- for (const key of sending) for (const vp of vps) {
307
+ for (const key of sending) for (const [item, vp] of slots(key)) {
239
308
  const [node, locale] = key.split('|')
240
- const c = byKey.get(`${node}|${locale}|${vp}`)
309
+ const c = byKey.get(`${node}|${item}|${locale}|${vp}`)
241
310
  const rec = c && await readJson(join(run.dir, c.file), null)
242
- const ev = await readJson(runPaths(run.dir).step(node, locale, vp, 'eval.json'), null)
311
+ const ev = await readJson(runPaths(run.dir).step(item ? `${node}/items/${item}` : node, locale, vp, 'eval.json'), null)
243
312
  if (rec && ev?.source !== evaluationSource(scenario.nodes.find((n) => n.id === node), rec)) unevaluated = true
244
313
  }
245
314
  if (unevaluated) { const { evaluateRun } = await import('./evaluate.mjs'); await evaluateRun({ run, scenario, config, root }); say('Evaluated the new captures against the scenario criteria.') }
@@ -250,11 +319,13 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
250
319
  const node = scenario.nodes.find((n) => n.id === rev.node)
251
320
  for (const [p, h] of Object.entries(rev.files)) files.set(h, { abs: join(dir, p), ext: extOf(p) })
252
321
  const captures = {}
253
- for (const vp of vps) {
254
- const c = byKey.get(`${rev.node}|${rev.locale}|${vp}`)
255
- if (!c) throw new UserError(`${rev.node} ${rev.locale} ${vp}: no capture; run kaloko walk first.`)
322
+ const itemCaptures = {}
323
+ for (const [item, vp] of slots(key)) {
324
+ const what = `${rev.node}${item ? `/${item}` : ''} ${rev.locale} ${vp}`
325
+ const c = byKey.get(`${rev.node}|${item}|${rev.locale}|${vp}`)
326
+ if (!c) throw new UserError(`${what}: no capture; run kaloko walk first.`)
256
327
  const rec = await readJson(join(run.dir, c.file))
257
- if (rec.live?.revision !== rev.id) throw new UserError(`${rev.node} ${rev.locale} ${vp}: capture does not match the folder; run kaloko walk again.`)
328
+ if (rec.live?.revision !== rev.id) throw new UserError(`${what}: capture does not match the folder; run kaloko walk again.`)
258
329
  const out = {}
259
330
  for (const [k, rel] of [['image', rec.files.image], ['thumb', rec.files.thumb], ['html', rec.files.html]]) {
260
331
  if (!rel) continue
@@ -263,14 +334,17 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
263
334
  files.set(h, { abs, ext: extOf(rel) })
264
335
  out[k] = h
265
336
  }
266
- const stored = await readJson(runPaths(run.dir).step(rev.node, rev.locale, vp, 'eval.json'), null)
267
- out.record = { ...slimCapture(rec), ...(rec.spec ? { spec: rec.spec } : {}) }
337
+ const stored = await readJson(runPaths(run.dir).step(item ? `${rev.node}/items/${item}` : rev.node, rev.locale, vp, 'eval.json'), null)
338
+ // a variant keeps what the stack index shows: its id, page and index fields; a step the page the capture
339
+ // rendered (a state's or the dark page, #46), so the canvas opens that page live
340
+ out.record = { ...slimCapture(rec), ...(rec.spec ? { spec: rec.spec } : {}), ...(item ? { item: rec.item, fields: rec.fields || {}, live: rec.live } : rec.live?.entry && rec.live.entry !== rev.entry ? { live: { entry: rec.live.entry } } : rec.live?.query ? { live: { query: rec.live.query } } : {}) }
268
341
  out.evaluation = stored?.source === evaluationSource(node, rec) ? stored : null
269
- out.mode = env.tokens_mode || ''
342
+ out.mode = rec.token_mode || env.tokens_mode || ''
270
343
  out.renderer = RENDERER
271
- captures[vp] = out
344
+ if (item) (itemCaptures[item] ||= {})[vp] = out
345
+ else captures[vp] = out
272
346
  }
273
- revisions.push({ id: rev.id, node: rev.node, locale: rev.locale, state: rev.state, entry: rev.entry, files: rev.files, tokens: rev.tokens, based_on: base[key] || remoteHeads[key] || null, captures })
347
+ revisions.push({ id: rev.id, node: rev.node, locale: rev.locale, state: rev.state, entry: rev.entry, ...(rev.items ? { items: rev.items, item_captures: itemCaptures } : {}), files: rev.files, tokens: rev.tokens, based_on: base[key] || remoteHeads[key] || null, captures })
274
348
  }
275
349
  await uploadBlobs({ client, org, files, say })
276
350
  const heads = toPush.map((key) => ({ node: local[key].node, locale: local[key].locale, state: local[key].state, revision: local[key].id, base: remoteHeads[key] || null }))
@@ -87,13 +87,35 @@ export const REVISION_ID = /^[0-9a-f]{32}$/
87
87
  * Identity of a revision: same content, same id, on every machine. `files` maps bundle paths to
88
88
  * blob hashes; `tokens` is the token set reference ({ project, version, hash }) or null.
89
89
  */
90
- export async function revisionId ({ scenario, node, locale, state = 'default', kind = 'design', entry, files, tokens = null }) {
91
- const canonical = JSON.stringify({ v: 1, scenario, node, locale, state, kind, entry, files: Object.keys(files).sort().map((k) => [k, files[k]]), tokens: tokens ? tokens.hash || null : null })
90
+ export async function revisionId ({ scenario, node, locale, state = 'default', kind = 'design', entry, files, tokens = null, items = null }) {
91
+ // a stack step's revision covers its whole folder and names its items (variants) with their pages: [{ id, entry }];
92
+ // steps without items keep the identity they always had
93
+ const canonical = JSON.stringify({ v: 1, scenario, node, locale, state, kind, entry, files: Object.keys(files).sort().map((k) => [k, files[k]]), tokens: tokens ? tokens.hash || null : null, ...(Array.isArray(items) ? { items: items.map((i) => [i.id, i.entry]) } : {}) })
92
94
  return (await sha256Hex(canonical)).slice(0, 32)
93
95
  }
94
96
 
95
- /** Key of a rendered capture: a revision rendered in one viewport and token mode by one renderer. */
96
- export const captureKey = (revision, viewport, mode = '') => `${revision}|${viewport}|${mode}|${RENDERER}`
97
+ /** Key of a rendered capture: a revision (or one item of a stack revision) rendered in one viewport and token mode by one renderer. */
98
+ export const captureKey = (revision, viewport, mode = '', item = '') => `${revision}${item ? `+${item}` : ''}|${viewport}|${mode}|${RENDERER}`
99
+
100
+ /** Item ids of a stack step: folder names (a path segment of the bundle), at most this many per step. */
101
+ export const ITEM_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
102
+ export const MAX_ITEMS = 200
103
+
104
+ /** Problems with the items of a stack revision ([{ id, entry }]) against its file list (empty when valid). */
105
+ export function itemProblems (items, files, node) {
106
+ if (!Array.isArray(items) || !items.length) return ['a stack revision needs at least one item']
107
+ const problems = []
108
+ if (items.length > MAX_ITEMS) problems.push(`more than ${MAX_ITEMS} items`)
109
+ const seen = new Set()
110
+ for (const it of items) {
111
+ if (!it || !ITEM_ID.test(it.id || '')) { problems.push(`item ${String(it?.id).slice(0, 40)}: invalid id`); continue }
112
+ if (seen.has(it.id)) problems.push(`item ${it.id}: listed twice`)
113
+ seen.add(it.id)
114
+ if (typeof it.entry !== 'string' || !it.entry.startsWith(`${node}/${it.id}/`) || extOf(it.entry) !== 'html') problems.push(`item ${it.id}: entry must be an .html page in ${node}/${it.id}/`)
115
+ else if (!(it.entry in files)) problems.push(`item ${it.id}: entry ${it.entry} is not among the files`)
116
+ }
117
+ return problems
118
+ }
97
119
 
98
120
  // ---- reference scanner: which bundle files an entry needs ----
99
121
 
@@ -19,10 +19,12 @@ const titleOf = (t, locale) => (t && typeof t === 'object' ? t[locale] ?? t.defa
19
19
  const flowOrder = (nodes) => nodes.filter((n) => n.kind !== 'decision').slice().sort((a, b) => (a.col ?? 0) - (b.col ?? 0) || (a.lane ?? 0) - (b.lane ?? 0))
20
20
 
21
21
  /** A qawalk.scenario.v1 document from a run manifest: a design version carries the whole flow (steps, criteria, edges). */
22
- export function scenarioFromManifest (m) {
22
+ export function scenarioFromManifest (m, { variants = {} } = {}) {
23
23
  const lanes = m.lanes || []
24
24
  const nodes = m.nodes.map((n) => {
25
25
  const own = (n.criteria || []).filter((c) => !c.pack)
26
+ // a stack step lists its variant folders (#61), so the exported scenario validates and walks the exported folder
27
+ const items = n.kind === 'stack' ? { items: { urls: variants[n.id]?.length ? variants[n.id] : (n.stack?.sample || []).map((x) => x.id) } } : {}
26
28
  const packs = [...new Set((n.criteria || []).filter((c) => c.pack).map((c) => c.pack))]
27
29
  return {
28
30
  id: n.id, kind: n.kind, title: n.title,
@@ -30,7 +32,7 @@ export function scenarioFromManifest (m) {
30
32
  ...(lanes.length && Number.isInteger(n.lane) ? { lane: n.lane } : {}), ...(n.session ? { session: n.session } : {}),
31
33
  // run manifests do not carry how a step is reached; the scenario needs it, so the export names the manual way
32
34
  ...(['decision', 'stack'].includes(n.kind) ? {} : { execution: n.execution?.strategy && n.execution?.instructions ? { strategy: n.execution.strategy, instructions: n.execution.instructions } : { strategy: 'manual', instructions: `Open ${typeof n.path === 'string' ? n.path : `the ${n.id} step`}.` } }),
33
- ...(own.length ? { criteria: own.map((c) => ({ id: c.id, text: c.text, check: c.check })) } : {}), ...(packs.length ? { packs } : {})
35
+ ...items, ...(own.length ? { criteria: own.map((c) => ({ id: c.id, text: c.text, check: c.check })) } : {}), ...(packs.length ? { packs } : {})
34
36
  }
35
37
  })
36
38
  return {
@@ -44,7 +46,10 @@ export function scenarioFromManifest (m) {
44
46
  /** Start page of an exported design: the steps in flow order with each locale's page and screenshot; plain HTML, no script. */
45
47
  export function exportIndexHtml ({ index }) {
46
48
  const loc0 = index.locales[0]
47
- const rows = index.steps.map((s) => `<li><h2>${esc(titleOf(s.title, loc0))} <small>${esc(s.id)}</small></h2>${s.purpose ? `<p>${esc(s.purpose)}</p>` : ''}<p>${index.locales.filter((l) => s.pages[l]).map((l) => `<a href="${esc(s.pages[l].split('/').map(enc).join('/'))}">${esc(l)}: ${esc(titleOf(s.title, l))}</a>`).join(' · ') || '<em>no page</em>'}</p>${s.screenshots.length ? `<p class="shots">${s.screenshots.map((x) => `<a href="${esc(x.file)}"><img src="${esc(x.file)}" alt="${esc(`${s.id} ${x.locale} ${x.viewport}`)}" loading="lazy" width="160"></a>`).join('')}</p>` : ''}</li>`).join('\n')
49
+ const href = (p) => esc(p.split('/').map(enc).join('/'))
50
+ // a stack step lists its variants: each one's page per locale (#61)
51
+ const variants = (s) => s.variants?.length ? `<ul>${s.variants.map((v) => `<li>${esc(v.id)}: ${index.locales.filter((l) => v.pages[l]).map((l) => `<a href="${href(v.pages[l])}">${esc(l)}</a>`).join(' · ')}</li>`).join('')}</ul>` : ''
52
+ const rows = index.steps.map((s) => `<li><h2>${esc(titleOf(s.title, loc0))} <small>${esc(s.id)}</small></h2>${s.purpose ? `<p>${esc(s.purpose)}</p>` : ''}<p>${index.locales.filter((l) => s.pages[l]).map((l) => `<a href="${href(s.pages[l])}">${esc(l)}: ${esc(titleOf(s.title, l))}</a>`).join(' · ') || '<em>no page</em>'}</p>${variants(s)}${s.screenshots.length ? `<p class="shots">${s.screenshots.map((x) => `<a href="${esc(x.file)}"><img src="${esc(x.file)}" alt="${esc(`${s.id} ${x.locale} ${x.viewport}`)}" loading="lazy" width="160"></a>`).join('')}</p>` : ''}</li>`).join('\n')
48
53
  return `<!doctype html>
49
54
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(index.scenario.title)} · ${esc(index.ref)}</title>
50
55
  <style>:root{color-scheme:light dark;--ink:#151514;--paper:#fff;--line:#ddd}@media (prefers-color-scheme:dark){:root{--ink:#eee;--paper:#151514;--line:#333}}body{margin:0;font:16px/1.5 system-ui,sans-serif;color:var(--ink);background:var(--paper)}main{max-width:960px;margin:0 auto;padding:32px 16px}ol{padding-left:20px}li{border-top:1px solid var(--line);padding:12px 0}h2{font-size:18px;margin:0}small{font-weight:400;opacity:.6}.shots img{border:1px solid var(--line);border-radius:4px;margin:0 8px 8px 0;height:auto}code{font-size:14px}</style></head>
@@ -89,7 +94,7 @@ async function runIdOf ({ client, org, scenarioId, ref, branch }) {
89
94
  }
90
95
  if (/^v\d+$/.test(ref)) {
91
96
  const v = (await client.request('GET', `/orgs/${org}/scenarios/${enc(scenarioId)}/versions`)).find((x) => String(x.version) === ref.slice(1))
92
- if (!v) throw new UserError(`${scenarioId} has no version ${ref}.`)
97
+ if (!v) throw new UserError(`${scenarioId} has no version ${ref}. kaloko history lists its versions.`)
93
98
  return v.id
94
99
  }
95
100
  return ref
@@ -111,14 +116,17 @@ export async function exportDesign ({ config, scenarioId, client, ref = 'design'
111
116
  const sink = exportSink(out)
112
117
  const fileOf = async (path) => {
113
118
  const res = draft ? await fetch(manifest.files_base + path) : await client.fetch(`/orgs/${org}/runs/${runId}/files/${path.split('/').map(enc).join('/')}`)
114
- if (!res.ok) throw new UserError(`${path}: HTTP ${res.status}`)
119
+ if (!res.ok) throw new UserError(`Downloading ${path} failed (HTTP ${res.status}). Run the export again; if it keeps failing, the file may have expired with its run.`)
115
120
  return Buffer.from(await res.arrayBuffer())
116
121
  }
117
122
  // the live files keep the design folder layout: links between steps work from disk exactly as in the folder
118
123
  const paths = new Map(), clashes = []
119
124
  const pages = {}
125
+ const variantPages = {}
120
126
  for (const s of revs.steps.filter((x) => x.state === 'default')) {
121
127
  ;(pages[s.node] ||= {})[s.locale] = s.entry
128
+ // the variants of a stack step (#61) are folders of the step: <step>/<variant>/index.html
129
+ for (const it of s.items || []) { safeExportPath(it.entry); ((variantPages[s.node] ||= {})[it.id] ||= {})[s.locale] = it.entry }
122
130
  for (const [p, hash] of Object.entries(s.files)) {
123
131
  safeExportPath(p) // checked before anything is written
124
132
  if (paths.has(p)) { if (paths.get(p).hash !== hash) clashes.push(p); continue }
@@ -143,16 +151,28 @@ export async function exportDesign ({ config, scenarioId, client, ref = 'design'
143
151
  screenshots.push({ locale, viewport, file, size: c.size || null })
144
152
  shots++
145
153
  }
146
- steps.push({ id: n.id, title: n.title, purpose: n.purpose || '', kind: n.kind, pages: pages[n.id] || {}, screenshots })
154
+ // a stack step's screenshots are its variants': screenshots/<step>/<variant>/<locale>/<viewport>.webp
155
+ if (n.stack?.items) {
156
+ const idx = JSON.parse((await fileOf(n.stack.items)).toString('utf8'))
157
+ for (const it of idx.items || []) for (const [locale, vps] of Object.entries(it.captures || {})) for (const [viewport, c] of Object.entries(vps || {})) {
158
+ if (!c?.png) continue
159
+ const file = `screenshots/${n.id}/${safeExportPath(it.id)}/${locale}/${viewport}.${c.png.split('.').pop()}`
160
+ await sink.put(file, await fileOf(c.png))
161
+ screenshots.push({ item: it.id, locale, viewport, file, size: c.size || null })
162
+ shots++
163
+ }
164
+ }
165
+ const variants = Object.entries(variantPages[n.id] || {}).map(([id, byLocale]) => ({ id, pages: byLocale }))
166
+ steps.push({ id: n.id, title: n.title, purpose: n.purpose || '', kind: n.kind, pages: pages[n.id] || {}, ...(variants.length ? { variants } : {}), screenshots })
147
167
  }
148
168
  const index = {
149
169
  schema: DESIGN_EXPORT_SCHEMA, scenario: { id: scenarioId, title: manifest.scenario?.title || scenarioId }, ref, run_id: runId,
150
170
  version: manifest.version ?? null, version_name: manifest.version_name ?? null, branch, exported_at: new Date().toISOString(),
151
171
  locales: manifest.scope.locales, viewports: manifest.scope.viewports, tokens: paths.has(TOKENS_FILE) ? TOKENS_FILE : null,
152
- formats: { pages: 'HTML, CSS, JS and assets in the design folder layout (<step>/index.html, <step>/<locale>/index.html, _shared/)', tokens: 'W3C Design Tokens (DTCG) JSON', scenario: 'qawalk.scenario.v1 YAML', screenshots: 'WebP or PNG per step, locale and viewport' },
172
+ formats: { pages: 'HTML, CSS, JS and assets in the design folder layout (<step>/index.html, <step>/<locale>/index.html, <stack step>/<variant>/index.html, _shared/)', tokens: 'W3C Design Tokens (DTCG) JSON', scenario: 'qawalk.scenario.v1 YAML', screenshots: 'WebP or PNG per step, locale and viewport' },
153
173
  steps
154
174
  }
155
- await sink.put('scenario.yml', `# Exported from Kaloko (${ref}); validate with: kaloko validate scenario.yml\n` + yaml.dump(scenarioFromManifest(manifest), { lineWidth: 140, noRefs: true }))
175
+ await sink.put('scenario.yml', `# Exported from Kaloko (${ref}); validate with: kaloko validate scenario.yml\n` + yaml.dump(scenarioFromManifest(manifest, { variants: Object.fromEntries(Object.entries(variantPages).map(([k, v]) => [k, Object.keys(v)])) }), { lineWidth: 140, noRefs: true }))
156
176
  await sink.put('kaloko.json', JSON.stringify(index, null, 2) + '\n')
157
177
  await sink.put('index.html', exportIndexHtml({ index }))
158
178
  await sink.close()
@@ -5,6 +5,8 @@
5
5
  import { readFile, readdir, stat } from 'node:fs/promises'
6
6
  import { join, relative, sep } from 'node:path'
7
7
  import { BUNDLE_LIMITS, SHARED_DIR, TOKENS_FILE, TEXT_TYPES, contentTypeOf, extOf, resolveRef } from './design-core.mjs'
8
+ import { listedItems } from './design.mjs'
9
+ import { originAllowed } from './design-origins.mjs'
8
10
 
9
11
  /** Rule id → level and a short reason (the fix is in each finding). */
10
12
  export const LINT_RULES = {
@@ -19,7 +21,9 @@ export const LINT_RULES = {
19
21
  'websocket': ['error', 'network connections are blocked (connect-src self)'],
20
22
  'too-large': ['error', 'over the bundle limits'],
21
23
  'tokens-json': ['error', 'tokens.json is not valid JSON'],
24
+ 'variant-page': ['error', 'a variant folder of a stack step without its index.html'],
22
25
  'storage': ['warn', 'storage and cookies throw in the sandboxed frame and are not shared between viewers'],
26
+ 'origin-not-allowed': ['warn', 'a font or asset origin the organization has not allowed yet; it does not load on the live step'],
23
27
  'nondeterministic': ['warn', 'random values and the current time change every capture'],
24
28
  'directory-link': ['warn', 'a link to a folder does not open its index.html from disk or a zip'],
25
29
  'broken-link': ['warn', 'a link to a page that is not in the folder'],
@@ -39,7 +43,8 @@ export const LINT_RULES = {
39
43
  'missing-step': ['warn', 'a scenario step without a design'],
40
44
  'external-link': ['info', 'a link that leaves the prototype'],
41
45
  'stray-folder': ['info', 'a folder that is not a step of the scenario'],
42
- 'no-tokens': ['info', 'no token set: the inspector cannot name values']
46
+ 'no-tokens': ['info', 'no token set: the inspector cannot name values'],
47
+ 'origins-unchecked': ['info', 'the organization\'s list of font and asset origins could not be read']
43
48
  }
44
49
  export const BUDGET = { file_bytes: 1024 * 1024, step_bytes: 5 * 1024 * 1024 }
45
50
 
@@ -50,21 +55,37 @@ const kb = (b) => `${Math.round(b / 1024)} kB`
50
55
 
51
56
  /**
52
57
  * Lints the files of a design folder. `files`: Map path → { bytes, text? } (text for text types), `steps`: step ids of
53
- * the scenario (null = every top-level folder but _shared). Returns findings { rule, level, file, line, message, fix }.
58
+ * the scenario (null = every top-level folder but _shared), `stacks`: stack step id → the variant folders its
59
+ * `items.urls` lists (null: discovered from subfolders), `locales` of the scenario. `origins`: { wanted, allowed,
60
+ * unchecked } font and asset origins (#46): `wanted` the design environment declares (`origins` in
61
+ * kaloko.config.yml), `allowed` the organization's list on the service (null when unknown; `unchecked` says it could
62
+ * not be read). A stylesheet, font or image from a wanted or allowed origin is no error; one the organization has not
63
+ * allowed yet is a warning. Scripts, fetches and media never load from other sites. Returns findings
64
+ * { rule, level, file, line, message, fix }.
54
65
  */
55
- export function lintFiles (files, { steps = null } = {}) {
66
+ export function lintFiles (files, { steps = null, stacks = {}, locales = [], origins = null } = {}) {
67
+ const wanted = origins?.wanted || [], allowed = Array.isArray(origins?.allowed) ? origins.allowed : null
56
68
  const out = []
57
69
  const add = (rule, file, line, message, fix = '') => out.push({ rule, level: LINT_RULES[rule][0], file, line: line || null, message, fix })
58
70
  const has = (p) => files.has(p)
59
71
  const isDir = (p) => [...files.keys()].some((f) => f.startsWith(p.replace(/\/?$/, '/')))
60
72
  let anyMotion = null, reducedMotion = false, usesVars = false
61
- const resource = (file, text, i, raw, { script = false, data = false } = {}) => {
73
+ const resource = (file, text, i, raw, { script = false, data = false, media = false } = {}) => {
62
74
  const u = raw.trim()
63
75
  if (!u || u.startsWith('#') || /^(about:blank|blob:)/i.test(u)) return
64
76
  const line = lineAt(text, i)
65
77
  if (/^data:/i.test(u)) { if (script) add('external-url', file, line, 'a script from a data: URL is blocked (script-src self)', 'Put the script into a .js file of the bundle.'); return }
66
78
  if (/^javascript:/i.test(u)) return
67
- if (/^(https?:)?\/\//i.test(u) || SCHEME.test(u)) return add('external-url', file, line, `loads ${u.slice(0, 120)} from another site; it will not load on the live step (CSP default-src 'self')`, data ? 'Live steps have no network: put the data into a JSON fixture of the bundle and fetch(\'./fixtures/….json\').' : 'Download the file into _shared/ (fonts as woff2) and reference it with a relative path.')
79
+ if (/^(https?:)?\/\//i.test(u) || SCHEME.test(u)) {
80
+ // fonts, stylesheets and images may come from an origin the organization allows (style-src, font-src, img-src)
81
+ const asset = /^(https:)?\/\//i.test(u) && !script && !data && !media
82
+ if (asset && (originAllowed(wanted, u) || (allowed && originAllowed(allowed, u)))) {
83
+ if (allowed && !originAllowed(allowed, u)) add('origin-not-allowed', file, line, `${new URL(u.startsWith('//') ? `https:${u}` : u).origin} is in origins of the design environment, but the organization does not allow it yet; it will not load on the live step`, 'Ask an organization admin to add it in Settings → Security → Fonts and assets of designs, or download the file into _shared/.')
84
+ return
85
+ }
86
+ const why = script ? 'scripts load only from the bundle, also from origins the organization allows' : data ? 'live steps have no network (connect-src self)' : media ? 'video and audio load only from the bundle' : 'it will not load on the live step unless the organization allows that origin'
87
+ return add('external-url', file, line, `loads ${u.slice(0, 120)} from another site; ${why}`, data ? 'Live steps have no network: put the data into a JSON fixture of the bundle and fetch(\'./fixtures/….json\').' : asset ? 'Download the file into _shared/ (fonts as woff2) and reference it with a relative path, or add its origin to origins of the design environment and ask an organization admin to allow it (Settings → Security).' : 'Download the file into _shared/ and reference it with a relative path.')
88
+ }
68
89
  if (u.startsWith('/')) return add('root-relative', file, line, `${u.slice(0, 120)} starts with /, which is the origin root on the live step and the disk root from file://`, `Use a path relative to ${file} (for example ../${SHARED_DIR}/…).`)
69
90
  const p = resolveRef(file, stripQ(u))
70
91
  if (!p) return add('outside-folder', file, line, `${u.slice(0, 120)} points outside the design folder`, 'Keep every file the page needs inside the design folder.')
@@ -99,7 +120,7 @@ export function lintFiles (files, { steps = null } = {}) {
99
120
  at(/\bnew\s+(?:XMLHttpRequest|EventSource|WebSocket)\b/g, (m, l) => add('websocket', file, l, `${m[0].replace(/^new\s+/, '')} needs a network the live step does not have (connect-src self)`, 'Read fixtures from JSON files in the bundle with fetch(\'./fixtures/…json\').'))
100
121
  at(/(?:^|[^.\w$])(?:eval\s*\(|new\s+Function\s*\(|set(?:Timeout|Interval)\s*\(\s*['"`])/g, (m, l) => add('eval', file, l, 'code from a string is blocked on the live step (no \'unsafe-eval\')', 'Pass a function instead of a string; do not use eval or new Function.'))
101
122
  at(/navigator\.serviceWorker/g, (m, l) => add('service-worker', file, l, 'service workers are refused on live steps and outlive the page', 'Remove the registration; prototypes need no offline cache.'))
102
- at(/\b(localStorage|sessionStorage|indexedDB|document\.cookie)\b/g, (m, l) => add('storage', file, l, `${m[1]} throws in the sandboxed live step (opaque origin) and is empty for every other viewer`, 'Keep prototype state in the URL (location.hash or ?query) or in memory; wrap any storage access in try/catch.'))
123
+ at(/\b(localStorage|sessionStorage|indexedDB|document\.cookie)\b/g, (m, l) => add('storage', file, l, `${m[1]} throws in the sandboxed live step (opaque origin) and is empty for every other viewer`, 'Keep state between steps in window.kaloko.state (the prototype state of the canvas) or in the URL (?query, #hash); wrap any storage access in try/catch.'))
103
124
  at(/\b(Date\.now\(\)|new Date\(\s*\)|Math\.random\(\)|crypto\.(?:randomUUID|getRandomValues)\()/g, (m, l) => add('nondeterministic', file, l, `${m[1].replace(/\($/, '()')} makes every capture different, so unchanged steps look changed`, 'Use fixed dates (new Date(\'2026-01-15T10:00\')) and a seeded generator for fake data.'))
104
125
  at(/(?:^|[^.\w$]|window\.)(alert|confirm|prompt)\s*\(/g, (m, l) => add('modal', file, l, `${m[1]}() is blocked in the sandbox (no allow-modals)`, 'Show an in-page <dialog> instead.'))
105
126
  at(/(?:^|[^.\w$])window\.open\s*\(/g, (m, l) => add('new-window', file, l, 'window.open is blocked in the sandbox (no allow-popups)', 'Navigate in the same frame (location.href = \'../next/index.html\').'))
@@ -113,8 +134,8 @@ export function lintFiles (files, { steps = null } = {}) {
113
134
  if (ext === 'css') css(file, t)
114
135
  else if (ext === 'js' || ext === 'mjs') js(file, t)
115
136
  else if (ext === 'html' || ext === 'svg') {
116
- for (const m of t.matchAll(/\s(src|poster|data-src)\s*=\s*(['"])([^'"]*)\2/gi)) resource(file, t, m.index, m[3], { script: /<script\b[^>]*$/i.test(t.slice(Math.max(0, m.index - 200), m.index)) })
117
- for (const m of t.matchAll(/<link\b([^>]*)>/gi)) { const h = m[1].match(/\shref\s*=\s*(['"])([^'"]*)\1/i); const rel = (m[1].match(/\srel\s*=\s*(['"]?)([^'"\s>]+)\1/i)?.[2] || '').toLowerCase(); if (h && !/^(canonical|alternate|author|help|license|next|prev|search|dns-prefetch|preconnect)$/.test(rel)) resource(file, t, m.index, h[2]) }
137
+ for (const m of t.matchAll(/\s(src|poster|data-src)\s*=\s*(['"])([^'"]*)\2/gi)) { const before = t.slice(Math.max(0, m.index - 200), m.index); resource(file, t, m.index, m[3], { script: /<script\b[^>]*$/i.test(before), media: m[1].toLowerCase() === 'src' && /<(video|audio|source|track)\b[^>]*$/i.test(before) }) }
138
+ for (const m of t.matchAll(/<link\b([^>]*)>/gi)) { const h = m[1].match(/\shref\s*=\s*(['"])([^'"]*)\1/i); const rel = (m[1].match(/\srel\s*=\s*(['"]?)([^'"\s>]+)\1/i)?.[2] || '').toLowerCase(); if (h && !/^(canonical|alternate|author|help|license|next|prev|search|dns-prefetch|preconnect)$/.test(rel)) resource(file, t, m.index, h[2], { script: rel === 'modulepreload' || /\sas\s*=\s*["']?script/i.test(m[1]) }) }
118
139
  for (const m of t.matchAll(/<(?:use|image|feImage)\b[^>]*\s(?:xlink:)?href\s*=\s*(['"])([^'"]*)\1/gi)) resource(file, t, m.index, m[2])
119
140
  for (const m of t.matchAll(/\ssrcset\s*=\s*(['"])([^'"]*)\1/gi)) for (const part of m[2].split(',')) resource(file, t, m.index, part.trim().split(/\s+/)[0])
120
141
  for (const m of t.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) css(file, m[1], m.index + m[0].indexOf(m[1]), t)
@@ -137,6 +158,7 @@ export function lintFiles (files, { steps = null } = {}) {
137
158
  try { JSON.parse(files.get(TOKENS_FILE).text) } catch (e) { add('tokens-json', TOKENS_FILE, null, e.message, 'Fix the JSON (kaloko tokens validate).') }
138
159
  if (!usesVars) add('tokens-unused', TOKENS_FILE, null, 'no var(--…) in any stylesheet', 'Write token values as CSS custom properties (--color-accent) and use var(--color-accent), so the inspector and kaloko tokens check see them.')
139
160
  } else add('no-tokens', '.', null, 'no tokens.json in the folder', 'Add a W3C DTCG token set (kaloko tokens pull, or write tokens.json).')
161
+ if (wanted.length && !allowed && origins?.unchecked) add('origins-unchecked', '.', null, `origins of the design environment (${wanted.join(', ')}) were not compared with the organization's list`, 'Sign in (kaloko login) so kaloko design lint can read which origins the organization allows.')
140
162
  // per step: everything under its folder plus the shared files (an upper bound of what a revision carries)
141
163
  const tops = [...new Set([...files.keys()].filter((p) => p.includes('/')).map((p) => p.split('/')[0]))].filter((d) => d !== SHARED_DIR)
142
164
  const shared = [...files].filter(([p]) => p.startsWith(`${SHARED_DIR}/`)).reduce((s, [, f]) => s + f.bytes, 0)
@@ -146,6 +168,17 @@ export function lintFiles (files, { steps = null } = {}) {
146
168
  else if (own + shared > BUDGET.step_bytes) add('large-file', `${d}/`, null, `${kb(own + shared)} with the shared files: over the ${kb(BUDGET.step_bytes)} budget of a step`, 'Compress images and fonts; drop unused files from _shared/.')
147
169
  if (steps && !steps.includes(d)) add('stray-folder', `${d}/`, null, `${d}/ is not a step of the scenario`, 'Rename it to a step id, or move shared files into _shared/.')
148
170
  }
171
+ // stack steps (#61): every variant is a folder with its own page, <step>/<variant>/index.html
172
+ for (const [step, listed] of Object.entries(stacks || {})) {
173
+ const under = [...files.keys()].filter((p) => p.startsWith(`${step}/`) && p.split('/').length > 2)
174
+ const subs = [...new Set(under.map((p) => p.split('/')[1]))].filter((n) => !locales.includes(n) && n !== 'dark' && !n.startsWith('_'))
175
+ for (const id of listed || subs) {
176
+ if (has(`${step}/${id}/index.html`) || locales.some((l) => has(`${step}/${id}/${l}/index.html`))) continue
177
+ // a discovered folder without any page holds assets, not a variant
178
+ if (!listed && !under.some((p) => p.startsWith(`${step}/${id}/`) && extOf(p) === 'html')) continue
179
+ add('variant-page', `${step}/${id}/`, null, `variant ${id} of the stack step ${step} has no ${step}/${id}/index.html`, `Add ${step}/${id}/index.html${listed ? `, or drop ${id} from items.urls` : ''}.`)
180
+ }
181
+ }
149
182
  if (steps) for (const s of steps) if (!has(`${s}/index.html`) && ![...files.keys()].some((p) => p.startsWith(`${s}/`) && p.endsWith('/index.html'))) add('missing-step', `${s}/`, null, `step ${s} has no ${s}/index.html`, `Add ${s}/index.html.`)
150
183
  const order = { error: 0, warn: 1, info: 2 }
151
184
  return out.sort((a, b) => order[a.level] - order[b.level] || a.file.localeCompare(b.file) || (a.line || 0) - (b.line || 0))
@@ -170,11 +203,12 @@ export async function readDesignFolder (dir) {
170
203
  return files
171
204
  }
172
205
 
173
- /** Lints a design folder; `scenario` (optional) names the steps. */
174
- export async function lintDesign ({ dir, scenario = null }) {
206
+ /** Lints a design folder; `scenario` (optional) names the steps, `origins` ({ wanted, allowed, unchecked }) see lintFiles. */
207
+ export async function lintDesign ({ dir, scenario = null, origins = null }) {
175
208
  const files = await readDesignFolder(dir)
176
209
  const steps = scenario ? scenario.nodes.filter((n) => n.kind !== 'decision' && n.kind !== 'external').map((n) => n.id) : null
177
- const findings = lintFiles(files, { steps })
210
+ const stacks = scenario ? Object.fromEntries(scenario.nodes.filter((n) => n.kind === 'stack').map((n) => [n.id, listedItems(n)])) : {}
211
+ const findings = lintFiles(files, { steps, stacks, locales: scenario?.locales || [], origins })
178
212
  return { dir, files: files.size, findings, errors: findings.filter((f) => f.level === 'error').length, warnings: findings.filter((f) => f.level === 'warn').length }
179
213
  }
180
214