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/package.json +1 -1
- package/schemas/config.v1.json +2 -0
- package/schemas/revision.v1.json +11 -0
- package/schemas/run.v1.json +12 -3
- package/schemas/scenario.v1.json +14 -0
- package/skills/kaloko/reference/design.md +93 -1
- package/skills/kaloko/reference/execution.md +6 -0
- package/skills/kaloko/reference/portable-html.md +12 -3
- package/skills/kaloko/reference/stacks.md +6 -0
- package/src/cli.mjs +95 -30
- package/src/lib/build.mjs +9 -46
- package/src/lib/capture.mjs +1 -1
- package/src/lib/design-cli.mjs +121 -47
- package/src/lib/design-core.mjs +26 -4
- package/src/lib/design-export.mjs +26 -6
- package/src/lib/design-lint.mjs +45 -11
- package/src/lib/design-origins.mjs +86 -0
- package/src/lib/design.mjs +112 -8
- package/src/lib/dimensions.mjs +66 -12
- package/src/lib/drift.mjs +1 -1
- package/src/lib/evaluate.mjs +6 -2
- package/src/lib/manifest-core.mjs +90 -5
- package/src/lib/spec-cli.mjs +7 -4
- package/src/lib/stack.mjs +5 -8
- package/src/lib/tokens.mjs +16 -0
- package/src/lib/walk-dimensions.mjs +31 -6
- package/viewer/index.html +138 -35
|
@@ -36,6 +36,23 @@ export function statusOf (verdicts, captured) {
|
|
|
36
36
|
return 'pass'
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Step states (#46): a step can be captured in several states (default, empty, error, loading); the state is the
|
|
40
|
+
// last part of a capture key (`mobile.state-empty`, `mobile.dark.state-error`). A key without it is the default.
|
|
41
|
+
export const STATE_PREFIX = 'state-'
|
|
42
|
+
/** Fields of a capture record that name its run dimensions (batch E, step states, token modes). */
|
|
43
|
+
export const DIM_FIELDS = ['browser', 'color_scheme', 'reduced_motion', 'forced_colors', 'display_mode', 'token_mode', 'step_state', 'device_preset']
|
|
44
|
+
/** The step state of a capture key: 'default' unless its last part is `state-<name>`. */
|
|
45
|
+
export function stateOfKey (key) {
|
|
46
|
+
const last = String(key).split('.').slice(1).pop() || ''
|
|
47
|
+
return last.startsWith(STATE_PREFIX) ? last.slice(STATE_PREFIX.length) : 'default'
|
|
48
|
+
}
|
|
49
|
+
/** States a scenario step is captured in: its `states:` with `default` first (a step without states: ['default']). */
|
|
50
|
+
export const statesOf = (node) => ['default', ...(Array.isArray(node?.states) ? node.states.filter((s) => s !== 'default') : [])]
|
|
51
|
+
/** Does a capture key belong to this step (its state is one of the step's states)? */
|
|
52
|
+
export const keyAppliesTo = (key, node) => statesOf(node).includes(stateOfKey(key))
|
|
53
|
+
/** Does a criterion apply to a capture of this state (`states:` on the criterion; none = every state)? */
|
|
54
|
+
export const appliesToState = (c, state) => !Array.isArray(c?.states) || !c.states.length || c.states.includes(state || 'default')
|
|
55
|
+
|
|
39
56
|
/** The capture fields a manifest keeps (the full probe record stays with the capture). */
|
|
40
57
|
export function slimCapture (cap) {
|
|
41
58
|
return {
|
|
@@ -45,7 +62,7 @@ export function slimCapture (cap) {
|
|
|
45
62
|
...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.platform ? { platform: cap.platform, device: cap.device || null } : {}),
|
|
46
63
|
...(cap.design ? { design: cap.design } : {}), ...(cap.capture ? { capture: cap.capture } : {}),
|
|
47
64
|
...(cap.criteria_hash ? { criteria_hash: cap.criteria_hash } : {}),
|
|
48
|
-
...Object.fromEntries(
|
|
65
|
+
...Object.fromEntries(DIM_FIELDS.filter((k) => cap[k]).map((k) => [k, cap[k]])),
|
|
49
66
|
...(cap.ignore?.length ? { ignore: cap.ignore } : {}), ...(cap.unstable ? { unstable: cap.unstable } : {}), ...(cap.keyboard ? { keyboard: cap.keyboard } : {})
|
|
50
67
|
}
|
|
51
68
|
}
|
|
@@ -58,9 +75,11 @@ export const keyboardOf = (k) => k ? { complete: !!k.complete, total: k.total ??
|
|
|
58
75
|
* captures: [{ locale, viewport, cap (capture record or slimCapture), ev (valid evaluation or null),
|
|
59
76
|
* files: { png, html, thumb?, eval? }, live? }]
|
|
60
77
|
*/
|
|
61
|
-
export function assembleNode ({ node, pos, locales, viewportNames, captures, attachments = [] }) {
|
|
62
|
-
const base = { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: pos.col, lane: pos.lane }
|
|
78
|
+
export function assembleNode ({ node, pos, locales, viewportNames: allKeys, captures, attachments = [] }) {
|
|
79
|
+
const base = { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: pos.col, lane: pos.lane, ...(node.states?.length ? { states: statesOf(node) } : {}) }
|
|
63
80
|
if (node.kind === 'decision') return { ...base, status: null }
|
|
81
|
+
// keys of states this step does not have are not missing captures (a step without `states:` has the default only)
|
|
82
|
+
const viewportNames = allKeys.filter((k) => keyAppliesTo(k, node))
|
|
64
83
|
const applicable = locales.filter((l) => !node.path || typeof node.path === 'string' || (node.path[l] ?? node.path.default))
|
|
65
84
|
const mine = captures.filter((c) => applicable.includes(c.locale) && viewportNames.includes(c.viewport))
|
|
66
85
|
const capturesOut = {}
|
|
@@ -73,7 +92,7 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
|
|
|
73
92
|
png: c.files.png, thumb: c.files.thumb || null, html: c.files.html || null, url: cap.url ?? null,
|
|
74
93
|
...(cap.platform ? { platform: cap.platform, device: cap.device || null, tree: c.files.tree || null } : {}),
|
|
75
94
|
// run dimensions (batch E): engine, colour scheme, motion, forced colours, display mode, device preset
|
|
76
|
-
...Object.fromEntries(
|
|
95
|
+
...Object.fromEntries(DIM_FIELDS.filter((k) => cap[k]).map((k) => [k, cap[k]])),
|
|
77
96
|
...(cap.http_status ? { http_status: cap.http_status } : {}),
|
|
78
97
|
...(cap.capture && (cap.capture.scale !== 1 || cap.capture.format === 'png' || cap.capture.masked || cap.capture.annotations?.length) ? { quality: cap.capture } : {}), size: [cap.size.width, cap.size.height],
|
|
79
98
|
boxes: cap.boxes, captured_at: cap.captured_at, console_errors: (cap.console_errors || []).length, console: (cap.console_errors || []).slice(0, 20), blocked_list: (cap.blocked_requests || []).slice(0, 20),
|
|
@@ -111,7 +130,7 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
|
|
|
111
130
|
// a criterion limited to other environments (environments: [production]) is not applicable on this walk:
|
|
112
131
|
// reported as such, and it does not make the step partial
|
|
113
132
|
const offEnv = entries.some((e) => e.not_applicable) && entries.every((e) => e.not_applicable || e.verdict === 'not_reached')
|
|
114
|
-
return { ...agg, verdict: aggregate(verdicts), ...(offEnv ? { not_applicable:
|
|
133
|
+
return { ...agg, verdict: aggregate(verdicts), ...(offEnv ? { not_applicable: entries.find((e) => e.not_applicable).not_applicable } : {}) }
|
|
115
134
|
})
|
|
116
135
|
const counting = criteria.filter((c) => !c.not_applicable)
|
|
117
136
|
const byLocale = {}
|
|
@@ -150,3 +169,69 @@ export function summarize (nodes, sessions = null) {
|
|
|
150
169
|
}
|
|
151
170
|
return summary
|
|
152
171
|
}
|
|
172
|
+
|
|
173
|
+
// ---- stack steps: one step, many pages of one template (or the variants of a design, #61) ----
|
|
174
|
+
|
|
175
|
+
export const STACK_DEFAULT_FIELDS = [
|
|
176
|
+
{ id: 'title', label: 'Title', from: 'title' },
|
|
177
|
+
{ id: 'h1', label: 'H1', from: 'h1' },
|
|
178
|
+
{ id: 'words', label: 'Words', from: 'words' },
|
|
179
|
+
{ id: 'http', label: 'HTTP', from: 'http' }
|
|
180
|
+
]
|
|
181
|
+
export const STACK_SEMANTIC_SAMPLE = 20
|
|
182
|
+
export const stackFieldsOf = (node) => (node.fields?.length ? node.fields.map((f) => ({ label: f.label || f.id, from: f.from || f.id, ...f })) : STACK_DEFAULT_FIELDS)
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A stack step's manifest node and its items index (qawalk.stack.v1), from the captures of its pages. Shared by the CLI
|
|
186
|
+
* build and the service, which composes design versions with stack steps from a draft.
|
|
187
|
+
* captures: [{ item, locale, viewport, cap (capture record), ev (valid evaluation or null), files: { png, thumb?, html?, eval } }]
|
|
188
|
+
* in walk order. Returns { node, index, file } where `file` is the index's path in the run.
|
|
189
|
+
*/
|
|
190
|
+
export function assembleStack ({ node, base, environment = null, captures, attachments = [] }) {
|
|
191
|
+
const fields = stackFieldsOf(node).map((f) => ({ id: f.id, label: f.label || f.id }))
|
|
192
|
+
const crit = (node.criteria || []).map((c) => ({ id: c.id, text: c.text, type: c.check.type, pack: c.pack || null, ...(appliesOnEnv(c, environment) ? {} : { not_applicable: 'environment' }) }))
|
|
193
|
+
// criteria limited to other environments do not decide the status of a page
|
|
194
|
+
const live = new Set(crit.filter((c) => !c.not_applicable).map((c) => c.id))
|
|
195
|
+
const byItem = new Map()
|
|
196
|
+
for (const c of captures) {
|
|
197
|
+
const { cap, ev } = c
|
|
198
|
+
let it = byItem.get(c.item)
|
|
199
|
+
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) }
|
|
200
|
+
;(it.captures[c.locale] ||= {})[c.viewport] = { png: c.files.png, thumb: c.files.thumb || null, html: c.files.html || null, url: cap.url, size: cap.size ? (Array.isArray(cap.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 } : {}), ...(c.live ? { live: c.live } : {}), ...Object.fromEntries(DIM_FIELDS.filter((k) => cap[k]).map((k) => [k, cap[k]])), eval: ev ? c.files.eval : null }
|
|
201
|
+
// a criterion limited to other step states (#46) does not decide this capture
|
|
202
|
+
it.results[`${c.locale}|${c.viewport}`] = ev ? Object.fromEntries((ev.criteria || []).filter((r) => r.not_applicable !== 'state').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]]))
|
|
203
|
+
if (!ev) it.stale = true
|
|
204
|
+
}
|
|
205
|
+
const items = [...byItem.values()].sort((a, b) => a.order - b.order).map(({ order, ...it }) => {
|
|
206
|
+
const per = Object.values(it.results)
|
|
207
|
+
const verdictOf = (id) => worst(per.map((r) => r[id]?.[0]).filter(Boolean))
|
|
208
|
+
const verdicts = crit.filter((c) => live.has(c.id)).map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
|
|
209
|
+
const status = statusOf(verdicts, per.length > 0)
|
|
210
|
+
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)]))
|
|
211
|
+
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) }
|
|
212
|
+
})
|
|
213
|
+
const summary = { pass: 0, partial: 0, fail: 0, skip: 0 }
|
|
214
|
+
for (const it of items) summary[it.status]++
|
|
215
|
+
const criteria = crit.map((c) => {
|
|
216
|
+
const counts = {}
|
|
217
|
+
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 }
|
|
218
|
+
return { ...c, counts, verdict: counts.fail ? 'fail' : counts.needs_review ? 'needs_review' : counts.not_evaluated ? 'not_evaluated' : counts.pass ? 'pass' : 'not_reached', by_locale: {} }
|
|
219
|
+
})
|
|
220
|
+
const semanticSample = node.items?.semantic_sample ?? STACK_SEMANTIC_SAMPLE
|
|
221
|
+
const file = `steps/${node.id}/items.json`
|
|
222
|
+
const index = { schema: 'qawalk.stack.v1', node: node.id, fields, criteria: crit, semantic_sample: semanticSample, items }
|
|
223
|
+
const thumbOf = (it) => { const c = Object.values(it.captures)[0]; const v = c && Object.values(c)[0]; return v ? v.thumb || v.png : null }
|
|
224
|
+
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) }))
|
|
225
|
+
const status = !items.length ? 'skip' : summary.fail ? 'fail' : summary.partial || summary.skip ? 'partial' : 'pass'
|
|
226
|
+
const locales = [...new Set(items.flatMap((it) => Object.keys(it.by_locale)))]
|
|
227
|
+
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'] }))
|
|
228
|
+
const src = node.items || {}
|
|
229
|
+
const out = {
|
|
230
|
+
...base, status, by_locale, captures: {}, criteria, semantic: {}, metadata: {}, session: node.session || null,
|
|
231
|
+
execution: node.execution ? { strategy: node.execution.strategy, instructions: node.execution.instructions } : { strategy: 'playwright', instructions: 'Open every page of the stack and capture it.' },
|
|
232
|
+
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 })),
|
|
233
|
+
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 }
|
|
234
|
+
}
|
|
235
|
+
return { node: out, index, file }
|
|
236
|
+
}
|
|
237
|
+
const appliesOnEnv = (c, environment) => !environment || !Array.isArray(c.environments) || !c.environments.length || c.environments.includes(environment)
|
package/src/lib/spec-cli.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { readJson, UserError } from './util.mjs'
|
|
|
7
7
|
import { readCaptures } from './run.mjs'
|
|
8
8
|
import { latestCaptures } from './evaluate.mjs'
|
|
9
9
|
import { specText, specDrift, driftText } from './devspec.mjs'
|
|
10
|
+
import { stateOfKey } from './manifest-core.mjs'
|
|
10
11
|
|
|
11
12
|
const enc = encodeURIComponent
|
|
12
13
|
|
|
@@ -27,19 +28,21 @@ export async function specCmd ({ config, client, run, scenario, scenarioId, args
|
|
|
27
28
|
const step = typeof args._[0] === 'string' ? args._[0] : typeof args.step === 'string' ? args.step : null
|
|
28
29
|
const locale = typeof args.locale === 'string' ? args.locale : null
|
|
29
30
|
const viewport = typeof args.viewport === 'string' ? args.viewport : null
|
|
31
|
+
// a step state (#46): its captures (keys <viewport>.state-<name>); default otherwise
|
|
32
|
+
const state = typeof args.state === 'string' ? args.state : null
|
|
30
33
|
const steps = step ? [step] : (scenario?.nodes || []).filter((n) => n.kind !== 'decision' && n.kind !== 'external').map((n) => n.id)
|
|
31
|
-
if (!steps.length) throw new UserError('Usage: kaloko spec <step> [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--local] [--json]')
|
|
34
|
+
if (!steps.length) throw new UserError('Usage: kaloko spec <step> [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--state s] [--local] [--json]')
|
|
32
35
|
const out = []
|
|
33
36
|
for (const s of steps) {
|
|
34
37
|
if (args.local || !client) {
|
|
35
38
|
if (!run) throw new UserError('--local needs a run: pass --run <id> or start one.')
|
|
36
|
-
const list = (await localSpecs(run, { step: s, locale, viewport })).filter((x) => locale || x.locale === (run.meta.scope?.locales?.[0] || x.locale))
|
|
37
|
-
const one = pickOne(list, viewport)
|
|
39
|
+
const list = (await localSpecs(run, { step: s, locale, viewport: viewport && state ? null : viewport })).filter((x) => (locale || x.locale === (run.meta.scope?.locales?.[0] || x.locale)) && stateOfKey(x.viewport) === (state || (viewport ? stateOfKey(viewport) : 'default')))
|
|
40
|
+
const one = pickOne(list, viewport && state && stateOfKey(viewport) === 'default' ? `${viewport}.state-${state}` : viewport)
|
|
38
41
|
if (!one) continue
|
|
39
42
|
out.push({ node: s, locale: one.locale, viewport: one.viewport, ref: 'local', spec: one.spec })
|
|
40
43
|
} else {
|
|
41
44
|
const ref = typeof args.ref === 'string' ? args.ref : typeof args.version === 'string' || typeof args.version === 'number' ? `v${args.version}` : 'design'
|
|
42
|
-
const q = new URLSearchParams(Object.entries({ ref, locale, viewport }).filter(([, v]) => v))
|
|
45
|
+
const q = new URLSearchParams(Object.entries({ ref, locale, viewport, state }).filter(([, v]) => v))
|
|
43
46
|
try {
|
|
44
47
|
const d = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenarioId)}/steps/${enc(s)}/spec?${q}`)
|
|
45
48
|
out.push({ node: s, locale: d.locale, viewport: d.viewport, ref: d.ref, spec: d.spec })
|
package/src/lib/stack.mjs
CHANGED
|
@@ -6,16 +6,13 @@ import { resolve, extname } from 'node:path'
|
|
|
6
6
|
import { pathToFileURL } from 'node:url'
|
|
7
7
|
import { createHash } from 'node:crypto'
|
|
8
8
|
import { UserError } from './util.mjs'
|
|
9
|
+
import { STACK_DEFAULT_FIELDS, STACK_SEMANTIC_SAMPLE, stackFieldsOf } from './manifest-core.mjs'
|
|
9
10
|
|
|
10
11
|
export const DEFAULT_LIMIT = 500
|
|
11
|
-
|
|
12
|
-
export const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
{ id: 'words', label: 'Words', from: 'words' },
|
|
16
|
-
{ id: 'http', label: 'HTTP', from: 'http' }
|
|
17
|
-
]
|
|
18
|
-
export const fieldsOf = (node) => (node.fields?.length ? node.fields.map((f) => ({ label: f.label || f.id, from: f.from || f.id, ...f })) : DEFAULT_FIELDS)
|
|
12
|
+
// fields and the semantic sample live with the pure stack assembly (manifest-core.mjs), which the service shares
|
|
13
|
+
export const DEFAULT_SEMANTIC_SAMPLE = STACK_SEMANTIC_SAMPLE
|
|
14
|
+
export const DEFAULT_FIELDS = STACK_DEFAULT_FIELDS
|
|
15
|
+
export const fieldsOf = stackFieldsOf
|
|
19
16
|
|
|
20
17
|
const slug = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
21
18
|
/** Stable, readable id of a page: the path as a slug (≤ 60 chars) plus a short hash when needed. */
|
package/src/lib/tokens.mjs
CHANGED
|
@@ -174,6 +174,22 @@ export function cssValue (type, v) {
|
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
/** Structural problems of a set or resolver: names, references that point nowhere, unreadable values. */
|
|
177
|
+
/**
|
|
178
|
+
* A token mode as CSS (#46): every token with a plain value as a custom property on :root, named after its path
|
|
179
|
+
* (`color.brand.primary` → `--color-brand-primary`), the way live pages reference tokens. Composite values
|
|
180
|
+
* (typography, shadow) also get one property per part (`--type-body-font-size`). A design step rendered in a mode
|
|
181
|
+
* gets this sheet after its own styles.
|
|
182
|
+
*/
|
|
183
|
+
export function modeCss (set, mode) {
|
|
184
|
+
const safe = (v) => String(v).replace(/[;{}<>\\]/g, '')
|
|
185
|
+
const lines = []
|
|
186
|
+
const prop = (name, v) => { if (v != null && (typeof v === 'string' || typeof v === 'number') && String(v).length < 400) lines.push(`--${name.replace(/[^A-Za-z0-9_-]+/g, '-')}:${safe(v)}`) }
|
|
187
|
+
for (const t of flattenTokens(set, mode)) {
|
|
188
|
+
if (t.value && typeof t.value === 'object' && !Array.isArray(t.value)) { for (const [k, v] of Object.entries(t.value)) prop(`${t.name}-${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}`, v) } else prop(t.name, t.value)
|
|
189
|
+
}
|
|
190
|
+
return lines.length ? `:root{${lines.join(';')}}` : ''
|
|
191
|
+
}
|
|
192
|
+
|
|
177
193
|
export function validateTokens (set) {
|
|
178
194
|
if (!set || typeof set !== 'object' || Array.isArray(set)) return ['A token set is a JSON object.']
|
|
179
195
|
const problems = []
|
|
@@ -2,19 +2,40 @@
|
|
|
2
2
|
// capture takes, and the lines the CLI prints. The rules themselves live in dimensions.mjs.
|
|
3
3
|
import { UserError } from './util.mjs'
|
|
4
4
|
import { isApp } from './apps/drivers.mjs'
|
|
5
|
-
import { resolveViewports, expandScope, dimensionsFrom, appDevices, variantOf, pickKeys, DIMENSIONS } from './dimensions.mjs'
|
|
5
|
+
import { resolveViewports, expandScope, dimensionsFrom, appDevices, variantOf, pickKeys, stateOf, FREE_VALUE, DIMENSIONS } from './dimensions.mjs'
|
|
6
6
|
|
|
7
7
|
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : Array.isArray(v) ? v : null)
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Token modes a design run renders (#46): the modes of the token set (`tokenModes`, from modesOf) whose names fit
|
|
11
|
+
* a capture key, the environment's tokens_mode first (it is the default, left out of keys). `token_modes:` in the
|
|
12
|
+
* environment, the scenario or --token-modes picks some (false: none). A set with only light and dark modes leaves
|
|
13
|
+
* them to the colour scheme (color_schemes: [light, dark]), as before, unless they are picked. Null when fewer than
|
|
14
|
+
* two modes are left.
|
|
15
|
+
*/
|
|
16
|
+
export function designTokenModes ({ tokenModes = [], chosen = null, env = {} }) {
|
|
17
|
+
const modes = (tokenModes || []).filter((m) => FREE_VALUE.test(m))
|
|
18
|
+
if (chosen === false || env.token_modes === false) return null
|
|
19
|
+
const want = chosen || (Array.isArray(env.token_modes) ? env.token_modes : null)
|
|
20
|
+
if (want) for (const m of want) if (!modes.includes(m)) throw new UserError(`Token mode "${m}" is not in the token set (its modes: ${modes.join(', ') || 'none'}).`)
|
|
21
|
+
let out = want ? modes.filter((m) => want.includes(m)) : modes
|
|
22
|
+
if (!want && out.every((m) => ['light', 'dark'].includes(m))) out = []
|
|
23
|
+
if (out.length < 2) return null
|
|
24
|
+
const first = env.tokens_mode && out.includes(env.tokens_mode) ? env.tokens_mode : out[0]
|
|
25
|
+
return [first, ...out.filter((m) => m !== first)]
|
|
26
|
+
}
|
|
27
|
+
|
|
9
28
|
/**
|
|
10
29
|
* The scope of a new run: viewports (sizes and device presets), app devices and the dimensions from the CLI,
|
|
11
30
|
* the scenario and the config. Web runs take every dimension; app runs take devices, colour scheme and motion;
|
|
12
|
-
* Design environments render in Chromium with presets at their whole screen
|
|
31
|
+
* Design environments render in Chromium with presets at their whole screen, in every mode of the token set
|
|
32
|
+
* (`tokenModes`). Steps with `states:` add the step state dimension on every platform.
|
|
13
33
|
*/
|
|
14
|
-
export function startScope ({ args = {}, scenario, config, env }) {
|
|
34
|
+
export function startScope ({ args = {}, scenario, config, env, tokenModes = [] }) {
|
|
15
35
|
const platform = scenario.platform || 'web'
|
|
16
36
|
const dims = dimensionsFrom({ args, scenario, config })
|
|
17
37
|
const design = env?.kind === 'design'
|
|
38
|
+
dims.token_modes = design ? designTokenModes({ tokenModes, chosen: dims.token_modes, env }) : null
|
|
18
39
|
if (isApp(platform) || design) {
|
|
19
40
|
for (const k of ['browsers', 'display_modes']) if (dims[k] && (args[k.replace(/_/g, '-')] || scenario[k])) throw new UserError(`${k.replace(/_/g, ' ')} apply to web runs; ${design ? 'design steps render in Chromium' : `a ${platform} app walks on its devices`}.`)
|
|
20
41
|
dims.browsers = null; dims.display_modes = null
|
|
@@ -70,7 +91,8 @@ export function appDeviceGroups (scope, only = null) {
|
|
|
70
91
|
const keys = Object.keys(scope?.viewports || {})
|
|
71
92
|
const bases = baseViewports(scope)
|
|
72
93
|
const devices = bases.filter((b) => scope.devices?.[b]?.app)
|
|
73
|
-
|
|
94
|
+
// one walk per state of a step: the keys of the default state; the others follow from them (stateKeys)
|
|
95
|
+
const keysOf = (base) => keys.filter((k) => variantOf(scope, k).viewport === base && stateOf(k) === 'default')
|
|
74
96
|
if (devices.length) {
|
|
75
97
|
const picked = devices.filter((id) => !only || only.includes(id))
|
|
76
98
|
if (!picked.length) throw new UserError(`This run has no device ${only.join(', ')} (its devices: ${devices.join(', ')}).`)
|
|
@@ -79,5 +101,8 @@ export function appDeviceGroups (scope, only = null) {
|
|
|
79
101
|
return [{ device: null, title: null, keys: bases.length ? keysOf(bases[0]) : [] }]
|
|
80
102
|
}
|
|
81
103
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Keys of one engine (a capture on the shared Chromium takes the Chromium keys), of the default step state unless
|
|
106
|
+
* `state` names another one (`*`: every state, a design walk).
|
|
107
|
+
*/
|
|
108
|
+
export const engineKeys = (scope, engine = 'chromium', state = 'default') => Object.keys(scope?.viewports || {}).filter((k) => variantOf(scope, k).browser === engine && (state === '*' || stateOf(k) === state))
|