kaloko 0.9.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.
package/src/lib/build.mjs CHANGED
@@ -4,10 +4,9 @@ import { existsSync } from 'node:fs'
4
4
  import { join, resolve } from 'node:path'
5
5
  import { readJson, writeJson } from './util.mjs'
6
6
  import { readCaptures, runPaths, listRuns } from './run.mjs'
7
- import { latestCaptures, evaluationSource, stepIdOf, appliesOn } from './evaluate.mjs'
8
- import { fieldsOf, DEFAULT_SEMANTIC_SAMPLE } from './stack.mjs'
7
+ import { latestCaptures, evaluationSource, stepIdOf } from './evaluate.mjs'
9
8
  import { layout } from './scenario.mjs'
10
- import { assembleNode, summarize, statusOf, worst } from './manifest-core.mjs'
9
+ import { assembleNode, assembleStack, summarize } from './manifest-core.mjs'
11
10
  import { packageRoot } from './schema.mjs'
12
11
  import { variantOf } from './dimensions.mjs'
13
12
 
@@ -148,53 +147,17 @@ async function runMedia ({ run, entries }) {
148
147
  */
149
148
  async function buildStack ({ run, node, base, captures, attachments }) {
150
149
  const paths = runPaths(run.dir)
151
- const fields = fieldsOf(node).map((f) => ({ id: f.id, label: f.label || f.id }))
152
- const environment = run.meta?.environment?.name || null
153
- const crit = (node.criteria || []).map((c) => ({ id: c.id, text: c.text, type: c.check.type, pack: c.pack || null, ...(appliesOn(c, environment) ? {} : { not_applicable: 'environment' }) }))
154
- // criteria limited to other environments do not decide the status of a page
155
- const live = new Set(crit.filter((c) => !c.not_applicable).map((c) => c.id))
156
- const byItem = new Map()
150
+ const list = []
157
151
  for (const c of captures.filter((x) => x.node === node.id && x.item)) {
158
152
  const cap = await readJson(join(run.dir, c.file))
159
- const evPath = paths.step(stepIdOf(c), c.locale, c.viewport, 'eval.json')
160
- const stored = await readJson(evPath, null)
153
+ const stored = await readJson(paths.step(stepIdOf(c), c.locale, c.viewport, 'eval.json'), null)
161
154
  const ev = stored?.source === evaluationSource(node, cap) ? stored : null
162
- let it = byItem.get(c.item)
163
- if (!it) { it = { id: c.item, path: cap.item?.path || null, url: cap.url, title: cap.item?.title || cap.fields?.title || cap.metadata?.title || null, fields: cap.fields || {}, captures: {}, results: {}, order: byItem.size }; byItem.set(c.item, it) }
164
- ;(it.captures[c.locale] ||= {})[c.viewport] = { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html, url: cap.url, size: cap.size ? [cap.size.width, cap.size.height] : null, boxes: cap.boxes, captured_at: cap.captured_at, console_errors: (cap.console_errors || []).length, console: (cap.console_errors || []).slice(0, 10), overflow: (cap.overflow || []).length, meta: cap.meta, ...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.capture?.full_page === false ? { first_screen: true } : {}), eval: ev ? `steps/${stepIdOf(c)}/${c.locale}/${c.viewport}.eval.json` : null }
165
- it.results[`${c.locale}|${c.viewport}`] = ev ? Object.fromEntries(ev.criteria.map((r) => [r.id, [r.verdict, r.evidence ? String(r.evidence).slice(0, 200) : null, r.score ?? null]])) : Object.fromEntries(crit.filter((x) => x.type !== 'semantic').map((x) => [x.id, ['not_evaluated', null, null]]))
166
- if (!ev) it.stale = true
167
- }
168
- const items = [...byItem.values()].sort((a, b) => a.order - b.order).map(({ order, ...it }) => {
169
- const per = Object.values(it.results)
170
- const verdictOf = (id) => worst(per.map((r) => r[id]?.[0]).filter(Boolean))
171
- const verdicts = crit.filter((c) => live.has(c.id)).map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
172
- const status = statusOf(verdicts, per.length > 0)
173
- const by_locale = Object.fromEntries(Object.keys(it.captures).map((l) => [l, statusOf(Object.entries(it.results).filter(([k]) => k.startsWith(`${l}|`)).flatMap(([, r]) => Object.entries(r).filter(([id]) => live.has(id)).map(([, x]) => x[0])), true)]))
174
- return { ...it, status, by_locale, fails: crit.filter((c) => live.has(c.id) && verdictOf(c.id) === 'fail').map((c) => c.id), review: crit.filter((c) => live.has(c.id) && ['needs_review', 'not_evaluated'].includes(verdictOf(c.id))).map((c) => c.id) }
175
- })
176
- const summary = { pass: 0, partial: 0, fail: 0, skip: 0 }
177
- for (const it of items) summary[it.status]++
178
- const criteria = crit.map((c) => {
179
- const counts = {}
180
- for (const it of items) { const v = worst(Object.values(it.results).map((r) => r[c.id]?.[0]).filter(Boolean)); if (v !== 'not_reached') counts[v] = (counts[v] || 0) + 1 }
181
- return { ...c, counts, verdict: counts.fail ? 'fail' : counts.needs_review ? 'needs_review' : counts.not_evaluated ? 'not_evaluated' : counts.pass ? 'pass' : 'not_reached', by_locale: {} }
182
- })
183
- const semanticSample = node.items?.semantic_sample ?? DEFAULT_SEMANTIC_SAMPLE
184
- const file = `steps/${node.id}/items.json`
185
- await writeJson(join(run.dir, file), { schema: 'qawalk.stack.v1', node: node.id, fields, criteria: crit, semantic_sample: semanticSample, items })
186
- const thumbOf = (it) => { const c = Object.values(it.captures)[0]; const v = c && Object.values(c)[0]; return v ? v.thumb || v.png : null }
187
- const sample = [...items.filter((it) => it.status === 'fail'), ...items.filter((it) => it.status !== 'fail')].slice(0, 4).map((it) => ({ id: it.id, title: it.title, path: it.path, status: it.status, thumb: thumbOf(it) }))
188
- const status = !items.length ? 'skip' : summary.fail ? 'fail' : summary.partial || summary.skip ? 'partial' : 'pass'
189
- const locales = [...new Set(items.flatMap((it) => Object.keys(it.by_locale)))]
190
- const by_locale = Object.fromEntries(locales.map((l) => { const s = items.map((it) => it.by_locale[l]).filter(Boolean); return [l, s.includes('fail') ? 'fail' : s.some((x) => x !== 'pass') ? 'partial' : 'pass'] }))
191
- const src = node.items || {}
192
- return {
193
- ...base, status, by_locale, captures: {}, criteria, semantic: {}, metadata: {}, session: node.session || null,
194
- execution: node.execution ? { strategy: node.execution.strategy, instructions: node.execution.instructions } : { strategy: 'playwright', instructions: 'Open every page of the stack and capture it.' },
195
- attachments: attachments.filter((a) => a.node === node.id && a.kind !== 'focus').map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale })),
196
- stack: { count: items.length, items: file, summary, fields, sample, semantic_sample: semanticSample, source: ['urls', 'file', 'sitemap', 'crawl', 'script'].find((k) => src[k]) || null, stale: items.filter((it) => it.stale).length }
155
+ // a variant of a design stack (#61) opens live in the lightbox: its page in the step's revision
156
+ list.push({ item: c.item, locale: c.locale, viewport: c.viewport, cap, ev, files: { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html, eval: `steps/${stepIdOf(c)}/${c.locale}/${c.viewport}.eval.json` }, ...(cap.live ? { live: cap.live } : {}) })
197
157
  }
158
+ const { node: out, index, file } = assembleStack({ node, base, environment: run.meta?.environment?.name || null, captures: list, attachments })
159
+ await writeJson(join(run.dir, file), index)
160
+ return out
198
161
  }
199
162
 
200
163
  /**
@@ -165,7 +165,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
165
165
  }
166
166
  }
167
167
  // the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
168
- if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens, scope?.variants?.[viewport]?.color_scheme || null)
168
+ if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens, scope?.variants?.[viewport]?.token_mode || scope?.variants?.[viewport]?.color_scheme || null)
169
169
  // regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
170
170
  const ignore = await resolveIgnore(page, node, locale, viewport)
171
171
  // what moves while the page sits still (walk --stability N): repeated screenshots, compared here
@@ -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>
@@ -117,8 +122,11 @@ export async function exportDesign ({ config, scenarioId, client, ref = 'design'
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()