kaloko 0.9.0 → 0.11.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.
@@ -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))]
@@ -237,7 +237,8 @@ async function defaultInstall (engine) {
237
237
 
238
238
  async function defaultEngine (engine) {
239
239
  const pw = await import('playwright')
240
- const browser = await pw[engine].launch({ headless: true })
240
+ const { launchWithRetry } = await import('./browser.mjs')
241
+ const browser = await launchWithRetry(pw[engine], { headless: true })
241
242
  const version = browser.version()
242
243
  await browser.close()
243
244
  return { version }
@@ -245,7 +246,8 @@ async function defaultEngine (engine) {
245
246
 
246
247
  async function defaultLaunch (config) {
247
248
  const { chromium } = await import('playwright')
248
- const browser = await chromium.launch({ headless: true, channel: config.browser?.channel || undefined })
249
+ const { launchWithRetry } = await import('./browser.mjs')
250
+ const browser = await launchWithRetry(chromium, { headless: true, channel: config.browser?.channel || undefined })
249
251
  const version = browser.version()
250
252
  await browser.close()
251
253
  return { version }
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++