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.
@@ -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
 
@@ -0,0 +1,86 @@
1
+ // Font and asset origins on live design steps (#46, item 3). An organization admin lists the origins its designs
2
+ // load fonts, stylesheets and images from (Google Fonts, Adobe Fonts, a company CDN); the live origin's CSP adds them
3
+ // to style-src, font-src and img-src, never to script-src, connect-src or anything else. Shared by the service
4
+ // (worker/src/live.js, worker/src/design-origins.js) and the CLI (`kaloko design lint`), so both read an entry alike.
5
+ //
6
+ // cleanAssetOrigin(s, { reserved }) → 'https://fonts.gstatic.com' or 'https://*.cdn.example.com'; throws on anything else
7
+ // cleanAssetOrigins(list, opts) → the cleaned, de-duplicated list (at most MAX_ASSET_ORIGINS); throws on a bad entry
8
+ // originAllowed(list, url) → is the URL's origin covered by an entry (wildcards cover subdomains, not the apex)
9
+ // designCsp(appOrigin, origins) → the Content-Security-Policy of a live design step
10
+
11
+ export const MAX_ASSET_ORIGINS = 20
12
+ /** Hosts of Kaloko itself: never a font or asset origin of a design (the app, the files host, the live origin). */
13
+ export const RESERVED_HOSTS = ['kaloko.app', 'kaloko-usercontent.com']
14
+
15
+ const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?'
16
+ const DOMAIN = new RegExp(`^(?=.{3,253}$)(?:${LABEL}\\.)+(?:[a-z][a-z0-9-]{0,61}[a-z0-9]|xn--[a-z0-9-]{1,59})$`)
17
+ const under = (host, base) => host === base || host.endsWith(`.${base}`)
18
+
19
+ /** One origin as typed (" HTTPS://Fonts.GoogleAPIs.com/ ") in its stored form; throws an Error that names the entry. */
20
+ export function cleanAssetOrigin (input, { reserved = [] } = {}) {
21
+ const raw = String(input ?? '').trim()
22
+ const shown = raw.slice(0, 80)
23
+ const s = raw.toLowerCase().replace(/\/$/, '')
24
+ const m = s.match(/^https:\/\/(\*\.)?([^/:?#@\s*]+)(?::(\d{1,5}))?$/)
25
+ if (!m) {
26
+ if (/^http:\/\//.test(s)) throw new Error(`Only https origins can be added: ${shown}`)
27
+ if (s.includes('*')) throw new Error(`A wildcard is allowed only as the first label (https://*.example.com): ${shown}`)
28
+ throw new Error(`Not an origin (https://host, without a path): ${shown}`)
29
+ }
30
+ const [, wild, host, port] = m
31
+ if (!DOMAIN.test(host)) throw new Error(`Not an origin (https://host, without a path): ${shown}`)
32
+ // a wildcard needs a registrable base at least: *.example.com, never *.com
33
+ if (wild && host.split('.').length < 2) throw new Error(`A wildcard needs a domain under it (https://*.example.com): ${shown}`)
34
+ if (port !== undefined && (Number(port) < 1 || Number(port) > 65535)) throw new Error(`Not an origin (https://host, without a path): ${shown}`)
35
+ for (const r of [...RESERVED_HOSTS, ...reserved].map((x) => String(x || '').toLowerCase().replace(/:\d+$/, '')).filter(Boolean)) {
36
+ if (under(host, r) || (wild && under(r, host))) throw new Error(`Kaloko's own hosts cannot be added: ${shown}`)
37
+ }
38
+ return `https://${wild || ''}${host}${port && port !== '443' ? `:${port}` : ''}`
39
+ }
40
+
41
+ /** A list (array, or text with one origin per line, commas or spaces) cleaned; throws on the first bad entry. */
42
+ export function cleanAssetOrigins (list, opts = {}) {
43
+ const items = (Array.isArray(list) ? list : String(list ?? '').split(/[\s,;]+/)).filter((x) => String(x ?? '').trim())
44
+ const out = []
45
+ for (const x of items) { const c = cleanAssetOrigin(x, opts); if (!out.includes(c)) out.push(c) }
46
+ if (out.length > MAX_ASSET_ORIGINS) throw new Error(`At most ${MAX_ASSET_ORIGINS} font and asset origins.`)
47
+ return out
48
+ }
49
+
50
+ /** Does an entry of `list` cover the origin of `url` (a URL or an origin)? Wildcards cover subdomains at any depth. */
51
+ export function originAllowed (list, url) {
52
+ let u
53
+ try { u = new URL(String(url).startsWith('//') ? `https:${url}` : String(url)) } catch { return false }
54
+ if (u.protocol !== 'https:') return false
55
+ return (list || []).some((entry) => {
56
+ const m = String(entry).match(/^https:\/\/(\*\.)?([^/:]+)(?::(\d+))?$/)
57
+ if (!m || (m[3] || '') !== u.port) return false
58
+ return m[1] ? u.hostname.endsWith(`.${m[2]}`) : u.hostname === m[2]
59
+ })
60
+ }
61
+
62
+ /**
63
+ * The CSP of a live design step. `origins` must already be cleaned (cleanAssetOrigins) and checked against the hosts
64
+ * of the instance: they are added to style-src, font-src and img-src only. Scripts, connections, frames, workers and
65
+ * forms stay on the step's own origin whatever the list says.
66
+ */
67
+ export function designCsp (appOrigin, origins = []) {
68
+ const extra = origins.length ? ` ${origins.join(' ')}` : ''
69
+ return [
70
+ "default-src 'self'",
71
+ "script-src 'self' 'unsafe-inline'",
72
+ `style-src 'self' 'unsafe-inline'${extra}`,
73
+ `img-src 'self' data: blob:${extra}`,
74
+ `font-src 'self' data:${extra}`,
75
+ "media-src 'self' blob:",
76
+ "connect-src 'self'",
77
+ "frame-src 'none'",
78
+ "worker-src 'none'",
79
+ "object-src 'none'",
80
+ "base-uri 'self'",
81
+ "form-action 'none'",
82
+ `frame-ancestors ${appOrigin}`,
83
+ // opaque origin even when opened directly: no cookies, storage or service workers
84
+ 'sandbox allow-scripts allow-forms'
85
+ ].join('; ')
86
+ }
@@ -4,7 +4,7 @@ import { createServer } from 'node:http'
4
4
  import { readFile, readdir, stat, mkdir, writeFile, copyFile, rm } from 'node:fs/promises'
5
5
  import { join, resolve, dirname, relative, sep } from 'node:path'
6
6
  import { UserError } from './util.mjs'
7
- import { bundleProblems, closure, contentTypeOf, revisionId, sha256Hex, TOKENS_FILE, SHARED_DIR, extOf } from './design-core.mjs'
7
+ import { bundleProblems, closure, contentTypeOf, revisionId, sha256Hex, TOKENS_FILE, SHARED_DIR, extOf, ITEM_ID, MAX_ITEMS, itemProblems } from './design-core.mjs'
8
8
 
9
9
  export const isDesign = (env) => env?.kind === 'design'
10
10
 
@@ -31,12 +31,25 @@ async function listFiles (dir, base = dir) {
31
31
  return out
32
32
  }
33
33
 
34
+ /**
35
+ * The page of a step state (#46): `<step>/<locale>/<state>.html` or `<step>/<state>.html` next to the entry, or null
36
+ * (the entry then renders with `?state=<name>`). `files` are the revision's paths (or a list of them).
37
+ */
38
+ export function statePage (entry, state, files) {
39
+ if (!state || state === 'default') return entry
40
+ const has = (p) => (Array.isArray(files) ? files.includes(p) : !!files?.[p])
41
+ const dir = entry.slice(0, entry.lastIndexOf('/') + 1)
42
+ const node = entry.split('/')[0]
43
+ for (const p of [`${dir}${state}.html`, `${node}/${state}.html`]) if (has(p)) return p
44
+ return null
45
+ }
46
+
34
47
  /**
35
48
  * The files of one step in one locale: `<step>/<locale>/…` overrides `<step>/…` (other locales' folders
36
49
  * are left out), plus every bundle file those reference. Returns { entry, files: [{ path, bytes, hash }] }
37
- * or null when the step has no folder.
50
+ * or null when the step has no folder. `states` (#46): the pages of the step's states travel with it.
38
51
  */
39
- export async function scanStep ({ dir, node, locale, locales }) {
52
+ export async function scanStep ({ dir, node, locale, locales, states = [] }) {
40
53
  const stepDir = join(dir, node)
41
54
  if (!await exists(stepDir)) return null
42
55
  const own = await listFiles(stepDir)
@@ -51,7 +64,9 @@ export async function scanStep ({ dir, node, locale, locales }) {
51
64
  const beside = (entryDir === `${node}/` ? common : mine.map((p) => p.slice(locale.length + 1))).filter((p) => !p.includes('/') && extOf(p) !== 'html')
52
65
  // a page of its own for the dark colour scheme (<step>/dark/index.html, or per locale): part of the revision
53
66
  const dark = own.includes(`${entryDir.slice(node.length + 1)}dark/index.html`) ? [`${entryDir}dark/index.html`] : []
54
- const roots = [entry, ...dark, ...beside.map((p) => entryDir + p)]
67
+ // pages of the step's states (<step>/<state>.html, or per locale): part of the revision like the dark page
68
+ const stateRoots = (states || []).filter((s) => s !== 'default').map((s) => statePage(entry, s, own.map((p) => `${node}/${p}`))).filter(Boolean)
69
+ const roots = [entry, ...dark, ...stateRoots, ...beside.map((p) => entryDir + p)]
55
70
  const paths = await closure({ roots, read: (p) => readFile(join(dir, p), 'utf8'), exists: (p) => isFile(join(dir, p)) })
56
71
  const files = []
57
72
  for (const path of paths) {
@@ -63,6 +78,94 @@ export async function scanStep ({ dir, node, locale, locales }) {
63
78
  return { entry, files }
64
79
  }
65
80
 
81
+ /**
82
+ * Items of a stack step as `items.urls` names them, when they name folders of the step (`refund`, `./refund/`,
83
+ * `refund/index.html`): a list of folder names. Null when the step lists app URLs (`/credit?reason=x`), uses another
84
+ * source (sitemap, file, crawl, script) or lists nothing: then the item folders are discovered.
85
+ */
86
+ export function listedItems (node) {
87
+ const urls = node?.items?.urls
88
+ if (!Array.isArray(urls) || !urls.length) return null
89
+ const ids = urls.map((u) => String(u).trim().replace(/^\.\//, '').replace(/\/index\.html$/, '').replace(/\/+$/, ''))
90
+ return ids.every((id) => ITEM_ID.test(id) && id !== '.' && id !== '..') ? [...new Set(ids)] : null
91
+ }
92
+
93
+ /** Folder names inside a step folder that are not items: locale overrides, the dark page and _private folders. */
94
+ const notItem = (name, locales) => locales.includes(name) || name === 'dark' || name.startsWith('_') || name.startsWith('.')
95
+
96
+ /**
97
+ * The item folders of a stack step (#61): `<step>/<item>/index.html` (or `<step>/<item>/<locale>/index.html`), in the
98
+ * order `items.urls` lists them, else every subfolder with a page, sorted by name. Returns [{ id, listed }]; a listed
99
+ * folder without a page is still returned, so the walk and the lint can name it.
100
+ */
101
+ export async function stackItems ({ dir, node, locales }) {
102
+ const stepDir = join(dir, node.id)
103
+ const listed = listedItems(node)
104
+ if (listed) return listed.map((id) => ({ id, listed: true }))
105
+ let entries = []
106
+ try { entries = await readdir(stepDir, { withFileTypes: true }) } catch { return [] }
107
+ const out = []
108
+ for (const e of entries.filter((x) => x.isDirectory() && !notItem(x.name, locales) && ITEM_ID.test(x.name)).sort((a, b) => a.name.localeCompare(b.name))) {
109
+ const has = await isFile(join(stepDir, e.name, 'index.html')) || (await Promise.all(locales.map((l) => isFile(join(stepDir, e.name, l, 'index.html'))))).some(Boolean)
110
+ if (has) out.push({ id: e.name, listed: false })
111
+ }
112
+ return out
113
+ }
114
+
115
+ /** Entry of a folder (`<base>/<locale>/index.html` over `<base>/index.html`) and the roots of its revision. */
116
+ async function folderRoots ({ dir, base, locale, locales, states = [] }) {
117
+ const own = await listFiles(join(dir, base))
118
+ const localeDirs = new Set(locales)
119
+ const common = own.filter((p) => !localeDirs.has(p.split('/')[0]))
120
+ const mine = own.filter((p) => p.split('/')[0] === locale)
121
+ const entry = mine.includes(`${locale}/index.html`) ? `${base}/${locale}/index.html` : common.includes('index.html') ? `${base}/index.html` : null
122
+ if (!entry) return null
123
+ const entryDir = entry.slice(0, entry.lastIndexOf('/') + 1)
124
+ const beside = (entryDir === `${base}/` ? common : mine.map((p) => p.slice(locale.length + 1))).filter((p) => !p.includes('/') && extOf(p) !== 'html')
125
+ const dark = own.includes(`${entryDir.slice(base.length + 1)}dark/index.html`) ? [`${entryDir}dark/index.html`] : []
126
+ // step states (#46) of a variant: <step>/<variant>/<state>.html (or per locale) next to its page
127
+ const ownPaths = own.map((p) => `${base}/${p}`)
128
+ const stateRoots = (states || []).filter((s) => s !== 'default').map((s) => [`${entryDir}${s}.html`, `${base}/${s}.html`].find((p) => ownPaths.includes(p))).filter(Boolean)
129
+ return { entry, roots: [entry, ...dark, ...stateRoots, ...beside.map((p) => entryDir + p)] }
130
+ }
131
+
132
+ /**
133
+ * The files of a stack step in one locale (#61): every item's page with what it references, and the optional overview
134
+ * (`<step>/index.html`). The revision covers the whole step folder; its entry is the overview, else the first item.
135
+ * Returns { entry, items: [{ id, entry }], files } or null when the step has no folder.
136
+ */
137
+ export async function scanStack ({ dir, node, locale, locales }) {
138
+ if (!await exists(join(dir, node.id))) return null
139
+ const found = await stackItems({ dir, node, locales })
140
+ if (!found.length) throw new UserError(`Design step ${node.id} is a stack: add a folder per variant with a page, ${node.id}/<variant>/index.html.`)
141
+ if (found.length > MAX_ITEMS) throw new UserError(`Design step ${node.id} has ${found.length} variants; a stack step holds at most ${MAX_ITEMS}.`)
142
+ const items = []
143
+ const roots = []
144
+ for (const it of found) {
145
+ const r = await folderRoots({ dir, base: `${node.id}/${it.id}`, locale, locales, states: node.states })
146
+ if (!r) throw new UserError(`Design step ${node.id}: variant ${it.id} has no page; add ${node.id}/${it.id}/index.html.`)
147
+ items.push({ id: it.id, entry: r.entry })
148
+ roots.push(...r.roots)
149
+ }
150
+ // the overview: only the step folder's own page and the files beside it, never the item folders (they come above)
151
+ const own = await listFiles(join(dir, node.id))
152
+ const overviewEntry = own.includes(`${locale}/index.html`) ? `${node.id}/${locale}/index.html` : own.includes('index.html') ? `${node.id}/index.html` : null
153
+ if (overviewEntry) {
154
+ const at = overviewEntry.slice(0, overviewEntry.lastIndexOf('/') + 1)
155
+ roots.unshift(overviewEntry, ...own.map((p) => `${node.id}/${p}`).filter((p) => p.startsWith(at) && !p.slice(at.length).includes('/') && extOf(p) !== 'html'))
156
+ }
157
+ const paths = await closure({ roots, read: (p) => readFile(join(dir, p), 'utf8'), exists: (p) => isFile(join(dir, p)) })
158
+ const files = []
159
+ for (const path of paths) {
160
+ const buf = await readFile(join(dir, path))
161
+ files.push({ path, bytes: buf.length, hash: await sha256Hex(buf) })
162
+ }
163
+ const entry = overviewEntry || items[0].entry
164
+ const problems = [...bundleProblems(files, entry), ...itemProblems(items, Object.fromEntries(files.map((f) => [f.path, f.hash])), node.id)]
165
+ if (problems.length) throw new UserError(`Design step ${node.id} (${locale}) breaks the bundle rules:\n - ${problems.join('\n - ')}`)
166
+ return { entry, items, files }
167
+ }
168
+
66
169
  /** Token set reference of the design folder (tokens.json), or null. */
67
170
  export async function tokensRef (dir, project) {
68
171
  const file = join(dir, TOKENS_FILE)
@@ -73,8 +176,9 @@ export async function tokensRef (dir, project) {
73
176
  }
74
177
 
75
178
  /** A revision of one step and locale, with its id. */
76
- export async function revisionOf ({ dir, scenario, node, locale, locales, tokens }) {
77
- const scan = await scanStep({ dir, node, locale, locales })
179
+ export async function revisionOf ({ dir, scenario, node, locale, locales, tokens, states = [], stack = null }) {
180
+ // `stack`: the scenario node of a stack step (#61); its revision covers every item folder; `states` (#46): their pages
181
+ const scan = stack ? await scanStack({ dir, node: stack, locale, locales }) : await scanStep({ dir, node, locale, locales, states })
78
182
  if (!scan) return null
79
183
  // the token set travels with every revision: the canvas inspector names values from it on the live origin
80
184
  if (tokens && !scan.files.some((f) => f.path === TOKENS_FILE)) {
@@ -82,8 +186,8 @@ export async function revisionOf ({ dir, scenario, node, locale, locales, tokens
82
186
  scan.files.push({ path: TOKENS_FILE, bytes: buf.length, hash: await sha256Hex(buf) })
83
187
  }
84
188
  const files = Object.fromEntries(scan.files.map((f) => [f.path, f.hash]))
85
- const id = await revisionId({ scenario, node, locale, entry: scan.entry, files, tokens })
86
- return { id, scenario, node, locale, state: 'default', kind: 'design', entry: scan.entry, files, sizes: Object.fromEntries(scan.files.map((f) => [f.path, f.bytes])), tokens }
189
+ const id = await revisionId({ scenario, node, locale, entry: scan.entry, files, tokens, items: scan.items || null })
190
+ return { id, scenario, node, locale, state: 'default', kind: 'design', entry: scan.entry, ...(scan.items ? { items: scan.items } : {}), files, sizes: Object.fromEntries(scan.files.map((f) => [f.path, f.bytes])), tokens }
87
191
  }
88
192
 
89
193
  /**
@@ -9,6 +9,7 @@
9
9
  import { UserError } from './util.mjs'
10
10
  import { devicePreset, emulationOf, presetSummary, presetNames } from './devices.mjs'
11
11
  import { launchWithRetry } from './browser.mjs'
12
+ import { STATE_PREFIX, stateOfKey, statesOf } from './manifest-core.mjs'
12
13
 
13
14
  export const BROWSERS = ['chromium', 'webkit', 'firefox']
14
15
  export const COLOR_SCHEMES = ['light', 'dark']
@@ -22,8 +23,15 @@ export const DIMENSIONS = [
22
23
  { name: 'color_scheme', list: 'color_schemes', values: COLOR_SCHEMES, default: 'light', suffix: (v) => v },
23
24
  { name: 'reduced_motion', list: 'reduced_motion', values: REDUCED_MOTION, default: 'no-preference', suffix: () => 'reduce' },
24
25
  { name: 'forced_colors', list: 'forced_colors', values: FORCED_COLORS, default: 'none', suffix: () => 'forced' },
25
- { name: 'display_mode', list: 'display_modes', values: DISPLAY_MODES, default: 'browser', suffix: (v) => v }
26
+ { name: 'display_mode', list: 'display_modes', values: DISPLAY_MODES, default: 'browser', suffix: (v) => v },
27
+ // token modes of a design's token set (#46): brand-a, high-contrast…; the default mode (the environment's
28
+ // tokens_mode, else the first) is the first of the list and is left out of keys
29
+ { name: 'token_mode', list: 'token_modes', values: null, default: null, suffix: (v) => `mode-${v}` },
30
+ // step states (#46): default, empty, error, loading or any name a step lists in `states:`; always the last part
31
+ { name: 'step_state', list: 'states', values: null, default: 'default', suffix: (v) => `${STATE_PREFIX}${v}` }
26
32
  ]
33
+ /** Names of token modes and states: they are parts of capture keys and file names. */
34
+ export const FREE_VALUE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/
27
35
  const ALIASES = { chrome: 'chromium', safari: 'webkit', gecko: 'firefox', 'no-motion': 'reduce', reduced: 'reduce', 'no_preference': 'no-preference', high_contrast: 'active', 'high-contrast': 'active', forced: 'active', pwa: 'standalone', installed: 'standalone' }
28
36
 
29
37
  const asList = (v) => v == null ? null : Array.isArray(v) ? v.map(String) : String(v).split(',').map((s) => s.trim()).filter(Boolean)
@@ -35,6 +43,11 @@ export function dimensionValues (dim, raw) {
35
43
  if (!list?.length) return null
36
44
  const out = []
37
45
  for (const v0 of list) {
46
+ if (!d.values) {
47
+ if (!FREE_VALUE.test(v0)) throw new UserError(`${d.list.replace(/_/g, ' ')}: "${v0}" is not a valid name (letters, digits, - and _; at most 40).`)
48
+ if (!out.includes(v0)) out.push(v0)
49
+ continue
50
+ }
38
51
  const v = ALIASES[v0.toLowerCase()] || v0.toLowerCase()
39
52
  if (!d.values.includes(v)) throw new UserError(`Unknown ${d.list.replace(/_/g, ' ')} value "${v0}". Use: ${d.values.join(', ')}.`)
40
53
  if (!out.includes(v)) out.push(v)
@@ -52,12 +65,28 @@ export function dimensionsFrom ({ args = {}, scenario = {}, config = {} } = {})
52
65
  const out = {}
53
66
  for (const d of DIMENSIONS) {
54
67
  const flag = d.list.replace(/_/g, '-')
68
+ if (d.name === 'step_state') { out.states = scenarioStates(scenario, args.states); continue }
69
+ // token modes come from the design's token set (startScope); here only an explicit choice
55
70
  const raw = args[flag] ?? args[d.list] ?? scenario?.[d.list] ?? walk[d.list]
56
- out[d.list] = dimensionValues(d.name, raw)
71
+ out[d.list] = raw === false || raw === 'false' || raw === 'none' ? (d.name === 'token_mode' ? false : null) : dimensionValues(d.name, raw)
57
72
  }
58
73
  return out
59
74
  }
60
75
 
76
+ /**
77
+ * The states of a run (#46): `default` and every state a step lists in `states:`, in scenario order; null when no
78
+ * step has states. `pick` (kaloko start --states) narrows them; default always stays.
79
+ */
80
+ export function scenarioStates (scenario, pick = null) {
81
+ const all = []
82
+ for (const n of scenario?.nodes || []) for (const s of statesOf(n)) if (!all.includes(s)) all.push(s)
83
+ if (all.length < 2) return null
84
+ const want = pick ? dimensionValues('step_state', pick) : null
85
+ if (want) for (const s of want) if (!all.includes(s)) throw new UserError(`No step of this scenario has the state "${s}" (states: ${all.slice(1).join(', ')}).`)
86
+ const out = want ? all.filter((s) => s === 'default' || want.includes(s)) : all
87
+ return out.length > 1 ? out : null
88
+ }
89
+
61
90
  /**
62
91
  * Viewports of a scenario: { name: [w, h] } as before, or names of device presets — as an array
63
92
  * (`[iphone-15, ipad-landscape, desktop]`) or as values (`phone: iphone-15`, `tablet: { device: ipad-portrait }`,
@@ -100,12 +129,14 @@ export function appDevices (ids, platform) {
100
129
  return { viewports, devices }
101
130
  }
102
131
 
103
- /** The capture key of a viewport and dimension values: defaults are left out. */
104
- export function variantKey (viewport, dims = {}) {
132
+ /** The capture key of a viewport and dimension values: defaults are left out (`defaults`: the run's own, token mode). */
133
+ export function variantKey (viewport, dims = {}, defaults = {}) {
105
134
  const parts = [viewport]
106
- for (const d of DIMENSIONS) { const v = dims[d.name]; if (v && v !== d.default) parts.push(d.suffix(v)) }
135
+ for (const d of DIMENSIONS) { const v = dims[d.name]; if (v && v !== (defaults[d.name] ?? d.default)) parts.push(d.suffix(v)) }
107
136
  return parts.join('.')
108
137
  }
138
+ /** Defaults a scope sets itself: the token mode left out of keys is the first of its modes. */
139
+ export const scopeDefaults = (scope) => (scope?.dimensions?.token_modes?.length ? { token_mode: scope.dimensions.token_modes[0] } : {})
109
140
 
110
141
  /**
111
142
  * Scope of a run: every combination of viewport and dimension values as a capture key with its size, and the
@@ -119,10 +150,11 @@ export function expandScope ({ locales, viewports, devices = {}, dims = {} }) {
119
150
  const out = {}; const variants = {}
120
151
  let combos = [{}]
121
152
  for (const [d, l] of used) combos = combos.flatMap((c) => l.map((v) => ({ ...c, [d.name]: v })))
153
+ const defaults = dims.token_modes?.length ? { token_mode: dims.token_modes[0] } : {}
122
154
  for (const [name, size] of Object.entries(viewports)) {
123
155
  const device = devices[name] || null
124
156
  for (const c of combos) {
125
- const key = variantKey(name, c)
157
+ const key = variantKey(name, c, defaults)
126
158
  let s = size
127
159
  // installed PWA: the whole screen of a preset without the browser's bars (the status bar stays)
128
160
  if (c.display_mode === 'standalone' && device?.screen) s = [size[0], Math.max(size[1], device.screen[1] - (device.safe?.top || 0))]
@@ -130,7 +162,7 @@ export function expandScope ({ locales, viewports, devices = {}, dims = {} }) {
130
162
  variants[key] = { viewport: name, browser: c.browser || 'chromium', ...c, ...(device ? { device: name } : {}) }
131
163
  }
132
164
  }
133
- if (Object.keys(out).length > 400) throw new UserError(`This scope has ${Object.keys(out).length} combinations of viewport, browser, colour scheme and motion; keep it under 400 (pick fewer viewports or values).`)
165
+ if (Object.keys(out).length > 400) throw new UserError(`This scope has ${Object.keys(out).length} combinations of viewport, browser, colour scheme, motion, token mode and state; keep it under 400 (pick fewer viewports or values).`)
134
166
  const dimensions = Object.fromEntries(used.map(([d, l]) => [d.list, l]))
135
167
  return { ...loc, viewports: out, variants, ...(used.length ? { dimensions } : {}), ...(Object.keys(devices).length ? { devices } : {}) }
136
168
  }
@@ -147,10 +179,27 @@ export function variantOf (scope, key) {
147
179
  else if (part === 'reduce') out.reduced_motion = 'reduce'
148
180
  else if (part === 'forced') out.forced_colors = 'active'
149
181
  else if (part === 'standalone') out.display_mode = 'standalone'
182
+ else if (part.startsWith('mode-')) out.token_mode = part.slice(5)
183
+ else if (part.startsWith(STATE_PREFIX)) out.step_state = part.slice(STATE_PREFIX.length)
150
184
  }
151
185
  return out
152
186
  }
153
187
 
188
+ /** The step state of a capture key ('default' when the key has none). */
189
+ export const stateOf = (key) => stateOfKey(key)
190
+
191
+ /**
192
+ * Keys of `state` for keys of the default state (a walk pass, a device): `mobile` → `mobile.state-empty`, only those
193
+ * the scope has. The default state gives the keys back.
194
+ */
195
+ export function stateKeys (scope, keys, state = 'default') {
196
+ if (!state || state === 'default') return keys.filter((k) => stateOfKey(k) === 'default')
197
+ return keys.filter((k) => stateOfKey(k) === 'default').map((k) => `${k}.${STATE_PREFIX}${state}`).filter((k) => scope?.viewports?.[k])
198
+ }
199
+
200
+ /** The states a walk captures a step in: its `states:` that the run has, default first. */
201
+ export const nodeStates = (scope, node) => statesOf(node).filter((s) => s === 'default' || scope?.dimensions?.states?.includes(s))
202
+
154
203
  /** The fields a capture record and a manifest capture carry for its dimensions (only those that were set). */
155
204
  export function variantFields (scope, key) {
156
205
  const v = scope?.variants?.[key]
@@ -167,7 +216,8 @@ export function variantFields (scope, key) {
167
216
  * `only` limits the engines (walk --browsers).
168
217
  */
169
218
  export function passesOf (scope, { only = null } = {}) {
170
- const keys = Object.keys(scope?.viewports || {})
219
+ // keys of other step states are captured by the same passes, one step script invocation per state
220
+ const keys = Object.keys(scope?.viewports || {}).filter((k) => stateOfKey(k) === 'default')
171
221
  const passes = new Map()
172
222
  for (const key of keys) {
173
223
  const v = variantOf(scope, key)
@@ -300,15 +350,19 @@ export function referenceKey (key, available, scope = null) {
300
350
  const have = new Set(available || [])
301
351
  if (have.has(key)) return key
302
352
  const v = variantOf(scope, key)
303
- const chromium = variantKey(v.viewport, { ...v, browser: 'chromium' })
353
+ const defaults = scopeDefaults(scope)
354
+ const chromium = variantKey(v.viewport, { ...v, browser: 'chromium' }, defaults)
304
355
  if (have.has(chromium)) return chromium
305
356
  // a reference without a colour scheme dimension (light by default) answers for light captures
306
- if (!v.color_scheme || v.color_scheme === 'light') { const plain = variantKey(v.viewport, { ...v, browser: 'chromium', color_scheme: null }); if (have.has(plain)) return plain }
357
+ if (!v.color_scheme || v.color_scheme === 'light') { const plain = variantKey(v.viewport, { ...v, browser: 'chromium', color_scheme: null }, defaults); if (have.has(plain)) return plain }
307
358
  return null
308
359
  }
309
360
 
310
- /** Token mode of a capture: its colour scheme when the token set has that mode, else the environment's mode. */
311
- export const tokenModeOf = (variant, modes = [], fallback = null) => (variant?.color_scheme && modes.includes(variant.color_scheme) ? variant.color_scheme : fallback)
361
+ /**
362
+ * Token mode of a capture: the token mode it was rendered in (#46), else its colour scheme when the token set has
363
+ * that mode, else the environment's mode.
364
+ */
365
+ export const tokenModeOf = (variant, modes = [], fallback = null) => (variant?.token_mode && modes.includes(variant.token_mode) ? variant.token_mode : variant?.color_scheme && modes.includes(variant.color_scheme) ? variant.color_scheme : fallback)
312
366
 
313
367
  /** Engines a scope needs (for `kaloko browser install` hints and doctor). */
314
368
  export const enginesOf = (scope) => [...new Set(Object.keys(scope?.viewports || {}).map((k) => variantOf(scope, k).browser))]
package/src/lib/drift.mjs CHANGED
@@ -85,7 +85,7 @@ export async function driftForCapture ({ node, entry, capture, runDir, ctx }) {
85
85
  const refCaps = !ctx.isDesign ? (await referenceStep(ctx, node.id, entry.locale))?.captures : null
86
86
  const refStyles = refCaps ? refCaps[referenceKey(entry.viewport, Object.keys(refCaps))]?.design?.styles : null
87
87
  // a dark capture is checked against the dark mode of the token set (DTCG modes light/dark)
88
- const off = tokenOffenders(capture.design?.styles, ctx.tokens, tokenModeOf({ color_scheme: capture.color_scheme }, modesOf(ctx.tokens), ctx.mode), refStyles)
88
+ const off = tokenOffenders(capture.design?.styles, ctx.tokens, tokenModeOf({ color_scheme: capture.color_scheme, token_mode: capture.token_mode }, modesOf(ctx.tokens), ctx.mode), refStyles)
89
89
  if (off == null) { results[c.id] = { ok: null, evidence: 'Capture has no style summary; capture this step again.' }; continue }
90
90
  results[c.id] = off.length
91
91
  ? { ok: false, evidence: `${off.length} value(s) outside the token set: ${off.slice(0, 5).map((o) => `${o.property} ${o.value}${o.suggest ? ` → ${o.suggest}` : ''}`).join('; ')}` }
@@ -8,6 +8,7 @@ import { KEYBOARD_ASSERTS } from './keyboard.mjs'
8
8
  import { needsDrift, driftForCapture, loadTokens, driftCacheDir } from './drift.mjs'
9
9
  import { packHelp } from './pack-help.mjs'
10
10
  import { tokenCriteria, storedValueChecks, STYLE_ASSERTS } from './assertions.mjs'
11
+ import { appliesToState } from './manifest-core.mjs'
11
12
 
12
13
  /** Latest capture per node/locale/viewport. */
13
14
  export function latestCaptures (entries) {
@@ -19,6 +20,8 @@ export function latestCaptures (entries) {
19
20
  export const criteriaHash = (node) => sha256(node.criteria || [])
20
21
  /** A criterion with `environments` (e.g. [production]) applies only there; without a known environment it applies. */
21
22
  export const appliesOn = (c, environment) => !environment || !Array.isArray(c.environments) || !c.environments.length || c.environments.includes(environment)
23
+ /** Environment and step state (#46): a criterion with `states:` applies only to captures of those states. */
24
+ export const appliesTo = (c, environment, capture) => appliesOn(c, environment) && appliesToState(c, capture?.step_state)
22
25
  /** Folder of a capture under steps/: the node, or steps/<node>/items/<item> for a page of a stack step. */
23
26
  export const stepIdOf = (e) => (e.item ? `${e.node}/items/${e.item}` : e.node)
24
27
  export const evaluationSource = (node, capture) => sha256({ capture, criteria: node.criteria || [], purpose: node.purpose || '' })
@@ -31,6 +34,7 @@ export const evaluationSource = (node, capture) => sha256({ capture, criteria: n
31
34
  export function combine ({ node, capture, jev, vision = null, thresholds, environment = null }) {
32
35
  return (node.criteria || []).map((c) => {
33
36
  if (!appliesOn(c, environment)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies on ${c.environments.join(', ')} only; this walk ran on ${environment}.`, na: true, not_applicable: 'environment' }
37
+ if (!appliesToState(c, capture?.step_state)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies to the state${c.states.length > 1 ? 's' : ''} ${c.states.join(', ')} only; this capture shows ${capture?.step_state || 'default'}.`, na: true, not_applicable: 'state' }
34
38
  if (c.check.type === 'deterministic') {
35
39
  const r = capture.checks?.[c.id]
36
40
  const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
@@ -106,7 +110,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
106
110
  const current = capture.criteria_hash === criteriaHash(node)
107
111
  // console triage needs the evaluator only when the capture has console errors
108
112
  const outOfSample = entry.item && !sampled.get(entry.node)?.has(entry.item)
109
- const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && appliesOn(c, environment) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
113
+ const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && appliesTo(c, environment, capture) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
110
114
  // quick signals ride along on every captured page state (also steps without semantic criteria)
111
115
  const signals = settings.signals !== false && !outOfSample && !!capture.state
112
116
  // an imported screen without page text (#11) has nothing to ask about: a person decides on the canvas
@@ -206,7 +210,7 @@ export function semanticPrefetch ({ scenario, config, root, environment = null }
206
210
  if (!semantic.length && settings.signals === false) continue
207
211
  queue.push(async () => {
208
212
  const capture = await readJson(join(runDir, e.file))
209
- const crit = semantic.filter((c) => !(c.check.about === 'console' && !(capture.console_errors || []).length))
213
+ const crit = semantic.filter((c) => appliesToState(c, capture.step_state) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
210
214
  const signals = settings.signals !== false && !!capture.state
211
215
  if ((!crit.length && !signals) || capture.criteria_hash !== criteriaHash(node)) return
212
216
  asked++