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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
5
5
  "keywords": [
6
6
  "qa",
@@ -193,7 +193,9 @@
193
193
  "kind": { "enum": ["app", "design"], "default": "app", "description": "app: the application under test (a web app at base_url, or a native app in the app block). design: a folder of live HTML steps served locally by the CLI (serve); its runs are published as design versions (#42)." },
194
194
  "serve": { "type": "string", "description": "kind: design. Folder with one sub-folder per step (<step>/index.html, optional <step>/<locale>/index.html) and shared files in _shared/; relative to the config." },
195
195
  "fixtures": { "type": "string", "description": "kind: design. Folder of JSON fixtures live steps may fetch instead of real APIs; relative to the config." },
196
+ "origins": { "type": "array", "maxItems": 20, "items": { "type": "string", "pattern": "^https://(\\*\\.)?[A-Za-z0-9.-]+(:[0-9]{1,5})?/?$" }, "description": "kind: design. Origins the design loads fonts, stylesheets and images from (https://fonts.googleapis.com, https://*.cdn.example.com). The organization allows them in Settings → Security; kaloko design lint warns about one it does not allow. Scripts never load from them." },
196
197
  "tokens_mode": { "type": "string", "description": "kind: design. Token mode (e.g. light, dark) steps are rendered in; default: the token set's first mode." },
198
+ "token_modes": { "oneOf": [{ "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$" } }, { "const": false }], "description": "kind: design. Token modes of the token set to render every step in, one capture per mode (keys <viewport>.mode-<name>); tokens_mode is the default and has the plain key. Not set: every mode of the set when it has two or more, except a set of only light and dark (left to color_schemes). false: only tokens_mode." },
197
199
  "html_view": { "type": "boolean", "default": false, "description": "Experimental (#42): keep a static HTML view of every capture (assets rehosted, scripts removed, secrets scrubbed). Refused on production unless the environment is readonly." },
198
200
  "base_url": {
199
201
  "oneOf": [
@@ -12,6 +12,12 @@
12
12
  "state": { "type": "string", "default": "default", "description": "default, a design state (empty, error, …) or view:<viewport> for an HTML view." },
13
13
  "kind": { "enum": ["design", "html_view"], "default": "design" },
14
14
  "entry": { "type": "string" },
15
+ "items": {
16
+ "type": ["array", "null"],
17
+ "description": "Stack steps (#61): the variants of the step, each a folder of the step with its own page (<step>/<id>/index.html). The revision covers the whole step folder; its entry is the overview page or the first variant's.",
18
+ "maxItems": 200,
19
+ "items": { "type": "object", "required": ["id", "entry"], "properties": { "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$" }, "entry": { "type": "string" } } }
20
+ },
15
21
  "files": { "$ref": "bundle.v1.json#/properties/files" },
16
22
  "tokens": { "type": ["object", "null"], "properties": { "project": { "type": ["string", "null"] }, "version": { "type": ["integer", "null"] }, "hash": { "type": ["string", "null"] } } },
17
23
  "based_on": { "type": ["string", "null"], "description": "The revision this one changed (a canvas edit, a sync on top of the draft)." },
@@ -31,6 +37,11 @@
31
37
  "mode": { "type": "string" }
32
38
  }
33
39
  }
40
+ },
41
+ "item_captures": {
42
+ "type": "object",
43
+ "description": "Stack steps (#61): item id → viewport → rendered capture of that variant, shaped like captures.",
44
+ "additionalProperties": { "type": "object", "additionalProperties": { "$ref": "#/properties/captures/additionalProperties" } }
34
45
  }
35
46
  }
36
47
  }
@@ -156,11 +156,11 @@
156
156
  },
157
157
  "viewports": {
158
158
  "type": "object",
159
- "description": "Capture key -> [width, height]. A key is the viewport name plus the dimension values that differ from the default, joined by dots (mobile, mobile.webkit, iphone-15.webkit.dark.reduce)."
159
+ "description": "Capture key -> [width, height]. A key is the viewport name plus the dimension values that differ from the default, joined by dots (mobile, mobile.webkit, iphone-15.webkit.dark.reduce). Token modes of a design add mode-<name>, step states state-<name> as the last part (mobile.mode-brand-b, mobile.dark.state-empty)."
160
160
  },
161
161
  "variants": {
162
162
  "type": "object",
163
- "description": "Capture key -> { viewport, browser, color_scheme?, reduced_motion?, forced_colors?, display_mode?, device? }. Absent in runs without dimensions: every key is then a plain viewport in Chromium.",
163
+ "description": "Capture key -> { viewport, browser, color_scheme?, reduced_motion?, forced_colors?, display_mode?, token_mode?, step_state?, device? }. Absent in runs without dimensions: every key is then a plain viewport in Chromium.",
164
164
  "additionalProperties": {
165
165
  "type": "object",
166
166
  "properties": {
@@ -170,13 +170,15 @@
170
170
  "reduced_motion": { "enum": ["no-preference", "reduce"] },
171
171
  "forced_colors": { "enum": ["none", "active"] },
172
172
  "display_mode": { "enum": ["browser", "standalone"] },
173
+ "token_mode": { "type": "string", "description": "Token mode of the design's token set the key was rendered in." },
174
+ "step_state": { "type": "string", "description": "Step state (default, empty, error, loading…) of the key." },
173
175
  "device": { "type": "string" }
174
176
  }
175
177
  }
176
178
  },
177
179
  "dimensions": {
178
180
  "type": "object",
179
- "description": "Values of each dimension the run walked (browsers, color_schemes, reduced_motion, forced_colors, display_modes) for the canvas switchers."
181
+ "description": "Values of each dimension the run walked (browsers, color_schemes, reduced_motion, forced_colors, display_modes, token_modes with the default mode first, states with default first) for the canvas switchers. A step that has no such state has no captures under its keys."
180
182
  },
181
183
  "devices": {
182
184
  "type": "object",
@@ -463,6 +465,8 @@
463
465
  "reduced_motion": { "type": "string" },
464
466
  "forced_colors": { "type": "string" },
465
467
  "display_mode": { "type": "string" },
468
+ "token_mode": { "type": "string", "description": "Token mode a design capture was rendered in." },
469
+ "step_state": { "type": "string", "description": "Step state of the capture (default, empty, error…)." },
466
470
  "device_preset": { "type": "string", "description": "Viewport name of the device preset the capture emulated." },
467
471
  "device": { "type": ["string", "null"] },
468
472
  "tree": { "type": ["string", "null"], "description": "UI tree of an app capture (qawalk.tree.v1)." },
@@ -562,6 +566,11 @@
562
566
  "type": "object",
563
567
  "description": "Page metadata per locale (meta, links, JSON-LD, microdata, headings)."
564
568
  },
569
+ "states": {
570
+ "type": "array",
571
+ "items": { "type": "string" },
572
+ "description": "Step states the step is captured in (default first), when its scenario step lists states."
573
+ },
565
574
  "session": {
566
575
  "type": [
567
576
  "string",
@@ -68,6 +68,7 @@
68
68
  "reduced_motion": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["no-preference", "reduce"] }, "description": "prefers-reduced-motion values (keys <viewport>.reduce)." },
69
69
  "forced_colors": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["none", "active"] }, "description": "forced-colors (Windows high contrast) values (keys <viewport>.forced)." },
70
70
  "display_modes": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["browser", "standalone"] }, "description": "PWA display modes: standalone captures the page as an installed app (display-mode media rules and matchMedia; the whole screen of a device preset)." },
71
+ "token_modes": { "oneOf": [{ "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$" } }, { "const": false }], "description": "Design environments: token modes of the token set to render every step in (keys <viewport>.mode-<name>). Not set: every mode of the set (see the environment's token_modes). false: only the environment's tokens_mode." },
71
72
  "devices": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" }, "description": "App scenarios: devices to walk on, e.g. pixel-8-api-34, iphone-15-ios-18 (emulators, simulators or real devices named in the environment's app.<platform>.devices). One capture key per device." },
72
73
  "environments": {
73
74
  "type": "array",
@@ -311,6 +312,13 @@
311
312
  ],
312
313
  "description": "For `kaloko walk --affected`: globs of repository files (relative to the config) that shape this step, next to the routes and imports Kaloko finds itself; `always` walks the step on every affected walk (sign-in, consent)."
313
314
  },
315
+ "states": {
316
+ "type": "array",
317
+ "minItems": 1,
318
+ "uniqueItems": true,
319
+ "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$", "not": { "enum": ["index"] } },
320
+ "description": "States this step is captured in besides the default one, e.g. [empty, error, loading]; any name works. Each state is one more capture per viewport (keys <viewport>.state-<name>) and a value of the State switcher on the canvas. Design steps render <step>/<state>.html (or <step>/<locale>/<state>.html), else <step>/index.html?state=<name>; walks of web and app steps run the step script once more per state with the argument state. `default` may be listed; it is always captured."
321
+ },
314
322
  "page": {
315
323
  "enum": ["landing", "work", "list", "settings", "form", "doc"],
316
324
  "description": "What kind of page this step is (see the scenario's page); the usability pack asks per type, e.g. one primary call to action only on landing pages."
@@ -542,6 +550,12 @@
542
550
  "minItems": 1,
543
551
  "items": { "type": "string" },
544
552
  "description": "Environments where this criterion applies, e.g. [production] for hreflang to the live domain, the live demo or the sitemap. On any other environment the walk reports it as not applicable (not_evaluated with na: environment) and it does not make the step partial."
553
+ },
554
+ "states": {
555
+ "type": "array",
556
+ "minItems": 1,
557
+ "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$" },
558
+ "description": "Step states this criterion applies to, e.g. [empty] for \"says the cart is empty\". Captures of other states report it as not applicable (na: state). Not set: every state of the step."
545
559
  }
546
560
  }
547
561
  },
@@ -37,6 +37,11 @@ design/
37
37
  images, fonts, module imports, `fetch('./fixtures/x.json')`). Links to other steps are not files it needs.
38
38
  - Live steps run sandboxed on their own origin with `connect-src 'self'`: no real APIs. Use JSON fixtures in
39
39
  the bundle.
40
+ - Web fonts and images from a service (Google Fonts, Adobe Fonts, the company CDN) load only from origins an
41
+ organization admin allows in Settings → Security. List the ones the design uses on the environment
42
+ (`design: { kind: design, serve: design, origins: [https://fonts.googleapis.com, https://fonts.gstatic.com] }`);
43
+ `kaloko design lint` warns about each the organization does not allow yet, so tell the person to ask an admin.
44
+ Scripts never load from those origins.
40
45
  - Links between steps are plain relative links to the page: `<a href="../payment/index.html">`. The canvas
41
46
  follows them; `data-kaloko-go="payment"` (or `data-qawalk-go`) moves to that step whatever the `href` says.
42
47
  - Reference token values as CSS custom properties named after the token path (`color.accent` →
@@ -48,6 +53,40 @@ design/
48
53
  `kaloko design lint` checks the folder; `kaloko sync` runs it first and stops on errors (things that break on
49
54
  the live step), warnings are printed.
50
55
 
56
+ ## Variants of one template (stack steps)
57
+
58
+ A step that comes in variants of one template (an e-mail per reason support can pick, a landing per city, a card per
59
+ plan) is one stack step ([stacks.md](stacks.md)), not one step per variant in a lane of its own:
60
+
61
+ ```yaml
62
+ - id: credit
63
+ kind: stack
64
+ title: Credit from support
65
+ items: { urls: [refund, late, damaged] } # folders of the step; without folder names: every subfolder with a page
66
+ fields: [{ id: reason, label: Reason, from: .reason }]
67
+ criteria: [...] # every variant meets them
68
+ ```
69
+
70
+ ```
71
+ design/credit/index.html optional overview (the step's entry; links into the variants)
72
+ design/credit/refund/index.html one folder per variant: its page and the files next to it
73
+ design/credit/refund/cs/index.html a locale override of one variant
74
+ design/credit/late/index.html
75
+ ```
76
+
77
+ - `items.urls` that name folders (`refund`, `./refund/`, `refund/index.html`) list the variants in that order. App
78
+ paths (`/credit?reason=refund`), other sources or no list: every subfolder with an `index.html` is a variant,
79
+ sorted by name. Locale folders, `dark/` and folders starting with `_` are never variants.
80
+ - One revision covers the whole step folder; changing a variant is a new revision of the step. The walk captures
81
+ every variant in every viewport (`items.viewports` narrows it) with the index fields and criteria, and reuses
82
+ captures per revision and variant.
83
+ - `kaloko sync` sends the variants' captures with the step; the published version shows the stack index with every
84
+ variant and its verdict, and each variant opens live in the lightbox.
85
+ - `kaloko design lint` reports a variant folder without its `index.html` as an error.
86
+ - `kaloko export --live` and `export --design` write the variant folders; the design export adds
87
+ `screenshots/<step>/<variant>/<locale>/<viewport>.webp`.
88
+ - Not for steps whose variants are different screens of a flow: those stay separate steps.
89
+
51
90
  ## Devices, dark mode and app screens
52
91
 
53
92
  Design steps can target device presets and colour schemes like any run ([devices.md](devices.md)):
@@ -68,6 +107,30 @@ color_schemes: [light, dark]
68
107
  step spec and the token check of a dark capture name the dark tokens, and drift compares dark with dark.
69
108
  - Browsers: design steps render in Chromium. Check the implementation in WebKit and Firefox instead.
70
109
 
110
+ ## Step states and token modes
111
+
112
+ A step lists its other states in the scenario (`states: [empty, error, loading]`; any name of letters, digits, `-`
113
+ and `_` works). Draw each one so it can be reviewed and built:
114
+
115
+ - A state is a page next to the step's page: `<step>/<state>.html`, per locale `<step>/<locale>/<state>.html`. It
116
+ travels with the step's revision like the dark page; give it its own fixtures (`<step>/empty.json`, or a literal
117
+ `fetch('./fixtures/cart-empty.json')`) so the closure finds them.
118
+ - Without that page the walk opens `<step>/index.html?state=<name>` and says so. Use it when one page switches its
119
+ states in JS (`new URLSearchParams(location.search).get('state')`); the fixture must still be next to the page or
120
+ fetched by a literal path.
121
+ - Captures of a state are keyed `<viewport>.state-<name>` (`mobile.dark.state-empty`). Criteria with
122
+ `states: [empty]` are judged on that state only; other criteria on every state.
123
+ - The canvas shows a State switcher next to the language; review mode and the grid follow it. A step without the
124
+ state shows its default capture with a note.
125
+
126
+ Token modes: when the token set has two or more modes (resolver contexts or mode groups), every step renders in each
127
+ mode. The walk sets the mode's tokens as custom properties on `:root` after the page's own styles and
128
+ `data-kaloko-mode="<mode>"` on `<html>`; a page that switches themes by class can read the attribute. Captures are
129
+ keyed `<viewport>.mode-<name>`; the environment's `tokens_mode` (else the first mode) is the default with the plain
130
+ key, and the canvas switches modes like colour schemes. A set with only `light`/`dark` modes follows the colour
131
+ scheme instead (`color_schemes: [light, dark]`), unless `token_modes` names them. `token_modes: [brand-a, brand-b]` (environment, scenario or `kaloko start
132
+ --token-modes`) picks modes; `false` turns it off. Live view shows the default mode; other modes are screenshots.
133
+
71
134
  **Native app screens as portable HTML.** A mockup of an iOS or Android screen is a full-screen page:
72
135
 
73
136
  - Size it to the screen, not to a document: `html, body { height: 100% }` and the main area `min-height: 100dvh`.
@@ -121,7 +184,36 @@ opens straight into the presentation of that step: share it with the person who
121
184
  without it, a step never rendered live) the presentation shows the screenshots.
122
185
  - From the local preview and an offline export, the steps open from the run's merged folder (`live/site/`), so plain
123
186
  relative links click through there too.
124
- - A link's query and hash travel to the next step (`../payment/index.html?plan=pro#card`): keep prototype state there.
187
+ - A link's query and hash travel to the next step (`../payment/index.html?plan=pro#card`): enough for one value.
188
+ For more, use the prototype state below.
189
+
190
+ ### Prototype state
191
+
192
+ Steps of a prototype share a small store, so a cart filled on one step shows on the next. The frame helper of the
193
+ live step provides `window.kaloko.state`; the canvas keeps it for the viewer in their browser tab, per draft or
194
+ version, and never sends it to the service. *Start over* in the presentation clears it.
195
+
196
+ ```html
197
+ <!-- payment/index.html: the seeds are what the step shows when nothing set it yet -->
198
+ <script type="application/json" id="seed" data-kaloko-state>{ "cart": [{ "name": "Tea", "price": 120 }] }</script>
199
+ <script>
200
+ const render = (s) => { /* draw s.cart */ }
201
+ // first the step's own seeds, so it looks right alone (from disk, in a capture, in an export)
202
+ render(JSON.parse(document.getElementById('seed').textContent))
203
+ // then what the prototype remembers: the helper loads after the page's scripts and the state comes a moment later
204
+ addEventListener('kaloko:state', (e) => render(e.detail.state))
205
+ </script>
206
+ ```
207
+
208
+ - `kaloko.state.get(key)`, `set(key, value)` (JSON, up to 64 kB; `null` removes the key), `update(key, fn)`,
209
+ `subscribe(fn)` (called with the whole state now and on every change; returns an unsubscribe function), `reset()`,
210
+ `ready` (a promise of the first state). The `kaloko:state` event on `window` carries the same state; scripts that
211
+ run before the helper use it.
212
+ - The seeds in `<script type="application/json" data-kaloko-state>` fill keys that nothing has set yet. Give every
213
+ step that reads state seeds that make it look right on its own: reviewers open steps directly, captures render
214
+ each step alone, and the local preview and exports have no helper, so `window.kaloko` is missing there.
215
+ - Write with `set` or `update` on the step where the person acts (`update('cart', (c) => [...(c || []), item])`),
216
+ read on the next. Keep it small: ids, counts and form values, not page markup.
125
217
 
126
218
  ## What changed in a version
127
219
 
@@ -34,6 +34,12 @@ call `kaloko session reset` themselves when the plan starts as a new visitor.
34
34
  to the node's person (`session.key`, `title`, `mailbox`, `account`, and
35
35
  `credentials` for that account in the run's environment; see
36
36
  [environments.md](environments.md#accounts-and-sign-in)).
37
+ - Step states: a step with `states: [empty, error]` runs its script once
38
+ more per state with `state` (`'default'` first, then `'empty'`, …); bring
39
+ the page into that state (route a mocked API response with `page.route`,
40
+ empty the cart) before the capture. Each run captures that state's keys
41
+ (`mobile.state-empty`). For a step an agent walks:
42
+ `kaloko capture --step <id> --state empty`. App scripts get `state` too.
37
43
  - `agent` — steps that need judgement or change often. Follow
38
44
  `execution.instructions`, then `kaloko capture --step <id>`.
39
45
  - `manual` — a person does it; you capture afterwards.
@@ -19,7 +19,14 @@ warnings, `--json` gives every finding with file, line, rule and fix.
19
19
  `https://`, no protocol-relative `//cdn…`, no paths starting with `/` (on the live origin `/` is the origin root, from
20
20
  disk it is the disk root). Errors: `external-url`, `root-relative`, `outside-folder`, `missing-file`.
21
21
  - **No CDNs.** Download libraries into `_shared/` and link them relatively. Fonts are bundled as **woff2**, subset to
22
- the characters used, with `@font-face` in the shared CSS; Google Fonts links do not load.
22
+ the characters used, with `@font-face` in the shared CSS.
23
+ - **Font and asset origins** are the one exception, for stylesheets, fonts and images only: an organization admin
24
+ allows origins such as `https://fonts.googleapis.com`, `https://fonts.gstatic.com`, `https://use.typekit.net` or
25
+ the company CDN in Settings → Security, and live steps load them. Declare the ones the design uses on the design
26
+ environment (`origins: [https://fonts.googleapis.com, https://fonts.gstatic.com]`); `kaloko design lint` then
27
+ takes them instead of an `external-url` error and warns (`origin-not-allowed`) about each the organization does
28
+ not allow yet. Scripts, module imports, `fetch` and video never load from them. A bundled font still works from
29
+ disk and from an export; prefer it when you can.
23
30
  - Only bundle file types (html, css, js, mjs, json, svg, woff2, woff, png, jpg, webp, avif, gif, mp4, webm, txt).
24
31
  Convert TTF/OTF fonts and other formats (`file-type`).
25
32
  - **Size budget:** a file over 1 MB or a step over 5 MB (with the shared files) warns (`large-file`); 10 MB per file
@@ -40,8 +47,10 @@ warnings, `--json` gives every finding with file, line, rule and fix.
40
47
 
41
48
  ## State and data
42
49
 
43
- - **State lives in the URL**, not in storage: `localStorage`, `sessionStorage`, IndexedDB and cookies throw in the
44
- sandboxed frame and are empty for every other viewer (`storage`). Use `location.hash` or the query
50
+ - **State between steps lives in `window.kaloko.state` or the URL**, not in storage: `localStorage`,
51
+ `sessionStorage`, IndexedDB and cookies throw in the sandboxed frame and are empty for every other viewer
52
+ (`storage`). For a cart, a chosen plan or details filled in on an earlier step use the prototype state
53
+ ([design.md → Prototype state](design.md#prototype-state)); for a single value the query and hash do
45
54
  (`?plan=pro#card`): a link's query and hash travel to the next step in the presentation, and work from disk.
46
55
  Wrap any storage access you keep in `try/catch` with a fallback.
47
56
  - **No network:** real APIs, `XMLHttpRequest`, `WebSocket`, `EventSource` do not connect (`websocket`). Put data in
@@ -126,5 +126,11 @@ two or three example paths), whether the limit cut the list, and that
126
126
  semantic criteria covered only the sample. Put the full list in the CSV or
127
127
  the MCP result rather than in the issue.
128
128
 
129
+ ## Design environments
130
+
131
+ On a design environment a stack step is a template with its variants: each variant is a folder of the step,
132
+ `design/<step>/<variant>/index.html`, listed by `items.urls` or discovered from subfolders, and every variant is
133
+ captured in every viewport. See [design.md](design.md#variants-of-one-template-stack-steps).
134
+
129
135
  Not yet supported for stacks: comparison with a baseline run per page and
130
136
  the documentation layer (latest captures per step).
package/src/cli.mjs CHANGED
@@ -50,6 +50,7 @@ import { walkDesign, syncDesign, publishVersion, previewNotes, historyCmd, refer
50
50
  import { screensCmd } from './lib/screens-cli.mjs'
51
51
  import { designDir, readBase, headKey } from './lib/design.mjs'
52
52
  import { lintDesign, formatLint } from './lib/design-lint.mjs'
53
+ import { cleanAssetOrigins } from './lib/design-origins.mjs'
53
54
  import { exportDesign } from './lib/design-export.mjs'
54
55
  import { driverFor, isApp, pngSize, ocr as ocrImage } from './lib/apps/drivers.mjs'
55
56
  import { captureApp } from './lib/apps/capture.mjs'
@@ -57,7 +58,7 @@ import { readTree, flatten } from './lib/apps/tree.mjs'
57
58
  import { recordSettings, recordingSession, parseRecord, recordingFiles, traceFiles, stripRecordings, withoutRecordings } from './lib/recording.mjs'
58
59
  import { nativeSession } from './lib/apps/recording-native.mjs'
59
60
  import { videoCmd } from './lib/video-cli.mjs'
60
- import { passesOf, enginesOf, contextOptionsOf, launchEngine, pickKeys, variantFields, variantOf, dimensionValues, BROWSERS } from './lib/dimensions.mjs'
61
+ import { passesOf, enginesOf, contextOptionsOf, launchEngine, pickKeys, variantFields, variantOf, dimensionValues, stateKeys, nodeStates, BROWSERS } from './lib/dimensions.mjs'
61
62
  import { startScope, scopeKeys, dimensionLine, baseViewports, engineKeys, appDeviceGroups } from './lib/walk-dimensions.mjs'
62
63
  import { deviceConfig, prepareAndroid, readyAndroid, finishAndroid, prepareSimulator, applyAppAppearance } from './lib/apps/devices.mjs'
63
64
  import { attachAndroidWebView } from './lib/apps/webview.mjs'
@@ -113,6 +114,9 @@ Run
113
114
  More dimensions: every step is captured in each combination (keys like
114
115
  mobile.webkit.dark); default from the scenario, then walk: in the config
115
116
  [--devices pixel-8-api-34,iphone-15-ios-18] App scenarios: the emulators, simulators or devices to walk on
117
+ [--states empty,error] Only these step states (states: on a step; keys like mobile.state-empty)
118
+ [--token-modes brand-a,brand-b | --token-modes none]
119
+ Design environments: the token modes to render (default every mode of the set)
116
120
  browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
117
121
  Shared Chromium with CDP (agents, scripts and people drive it)
118
122
  browser install [--with-deps] [--browsers chromium,webkit,firefox]
@@ -121,8 +125,8 @@ Run
121
125
  devices [--json] Device presets for viewports: (phones, foldables, tablets, laptop, desktop, TV)
122
126
  open --step <id> [--locale l] Navigate the current page to the step URL
123
127
  session reset New visitor: clear cookies and storage in the shared browser
124
- capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b]
125
- Capture the current page for a step in all viewports
128
+ capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b] [--state <name>]
129
+ Capture the current page for a step in all viewports (--state: as that step state)
126
130
  capture --step <id> --crop <css> [--title <text>]
127
131
  Attach an element screenshot to a step
128
132
  capture --step <id> --image <shot.png> [--tree <dump.xml|json> | --ocr] [--platform android|ios|desktop]
@@ -307,9 +311,11 @@ Design (environments with kind: design; live HTML steps in a folder)
307
311
  sync [--pull|--push] [--branch b] [--mine a,b|--theirs a,b] [--no-lint]
308
312
  Exchange revisions with the shared draft; only changed steps and missing files travel
309
313
  Lints the folder first: errors (things that break on the live step) stop the push
310
- design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--json]
314
+ design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--offline] [--json]
311
315
  Check the folder against the portable HTML rules (relative URLs, no CDNs, no storage,
312
- deterministic content, viewport, lang, links that also work from disk); --strict fails on warnings
316
+ deterministic content, viewport, lang, links that also work from disk); --strict fails on warnings.
317
+ Fonts, styles and images from origins of the environment are compared with the
318
+ organization's allowed origins (--offline skips that)
313
319
  share [--name <text>] [--branch b] [--notes file|-] [--notes-preview]
314
320
  Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged",
315
321
  with "what changed" notes per step (screens per viewport, text, tokens); --notes-preview
@@ -321,7 +327,7 @@ Design (environments with kind: design; live HTML steps in a folder)
321
327
  a folder exported from Figma works without a token. One way: no prototype links
322
328
  history [--scenario id] [--step id] [--locale l] [--json]
323
329
  Versions of the flow; revisions of a step with who (person or agent) and when
324
- spec [step] [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--local] [--json]
330
+ spec [step] [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--state s] [--local] [--json]
325
331
  What to build for a step as approved: elements with role, name and selector, styles
326
332
  mapped to DTCG tokens (or the nearest one), :hover/:focus/:active/:disabled, contrast
327
333
  drift [step] [--ref design|vN] [--remote <run id|latest>] [--json]
@@ -457,6 +463,21 @@ function stepOf (scenario, id) {
457
463
  return node
458
464
  }
459
465
 
466
+ /**
467
+ * Font and asset origins for `kaloko design lint` (#46): `wanted` from `origins` of the design environment, and with
468
+ * `online` the organization's list from the service (GET /me design_origins; null when not signed in or unreachable).
469
+ */
470
+ async function lintOrigins (config, env, { online = false } = {}) {
471
+ let wanted = []
472
+ try { wanted = cleanAssetOrigins(env?.origins || []) } catch (e) { throw new UserError(`Environment ${env.name}, origins: ${e.message}`) }
473
+ if (!online || !wanted.length) return { wanted, allowed: null }
474
+ let timer
475
+ try {
476
+ const me = await Promise.race([service(config).request('GET', '/me'), new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 8000) })])
477
+ return Array.isArray(me?.design_origins) ? { wanted, allowed: me.design_origins } : { wanted, allowed: null, unchecked: true }
478
+ } catch { return { wanted, allowed: null, unchecked: true } } finally { clearTimeout(timer) }
479
+ }
480
+
460
481
  /** Scenario of --scenario (id or file) or of the current run. */
461
482
  /**
462
483
  * kaloko design import figma <file-url|key|folder>: Figma frames (or a folder exported from Figma) become the steps of
@@ -488,7 +509,7 @@ async function designImport (args) {
488
509
  if (args.json) say(JSON.stringify(r, null, 2))
489
510
  else say(formatImport(r, { dir: relative(process.cwd(), dir) || '.' }))
490
511
  if (args['dry-run']) return r
491
- const lint = await lintDesign({ dir, scenario })
512
+ const lint = await lintDesign({ dir, scenario, origins: await lintOrigins(config, env) })
492
513
  if (lint.errors) say(`\n${formatLint(lint, { max: 10 })}`)
493
514
  if (!args.publish) {
494
515
  say(`\nNext: kaloko start --scenario ${relative(process.cwd(), scenarioFile)} --env ${env.name} && kaloko walk && kaloko share (or rerun with --publish).\nAccept the version on the canvas and set it as the design reference (kaloko reference --design --version <n>): implementation runs with packs: [design] are then checked against these frames.`)
@@ -521,6 +542,24 @@ function manualKeys (run, pick) {
521
542
  return scopeKeys(scope, keys.length ? keys : engineKeys(scope, 'chromium'), null)
522
543
  }
523
544
 
545
+ /** Modes of a design environment's token set (tokens.resolver.json or tokens.json); [] without one. */
546
+ async function designTokenModesOf (config, root, env) {
547
+ try {
548
+ const { loadTokens } = await import('./lib/drift.mjs')
549
+ const { modesOf } = await import('./lib/tokens.mjs')
550
+ const set = await loadTokens({ client: null, config, root, designDir: designDir(env, root) })
551
+ return set ? modesOf(set) : []
552
+ } catch { return [] }
553
+ }
554
+
555
+ /** --state <name> (#46): the keys of that state of the step instead of the default ones. */
556
+ function inState (run, node, keys, state) {
557
+ if (!state || state === true || state === 'default') return keys
558
+ const have = nodeStates(run.meta.scope, node)
559
+ if (!have.includes(state)) throw new UserError(`Step ${node.id} has no state "${state}" in this run (${have.length > 1 ? `its states: ${have.join(', ')}` : 'add states: to the step and start a new run'}).`)
560
+ return Object.fromEntries(stateKeys(run.meta.scope, Object.keys(keys), state).map((k) => [k, run.meta.scope.viewports[k]]))
561
+ }
562
+
524
563
  /** { key: [w, h] } a capture on a Chromium page takes: every Chromium key of the run, or those of `pick`. */
525
564
  function scopeViewports (run, pick) {
526
565
  return scopeKeys(run.meta.scope, engineKeys(run.meta.scope, 'chromium'), pick)
@@ -772,15 +811,17 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
772
811
  if (!scripted && !(link && ['playwright', 'script'].includes(node.execution?.strategy))) { pending.push(`${node.id} (${locale}): ${node.execution?.strategy || 'agent'} — ${(node.execution?.instructions || '').split('\n')[0]}`); continue }
773
812
  const streakKey = `${group.device || ''}|${locale}`
774
813
  if (streak.stopped(streakKey)) { skipped.push(`${node.id} (${locale}${tag})`); continue }
775
- const records = []
814
+ let records = []
815
+ // step states (#46): the script (or the deep link) runs once per state; `state` tells the script which
816
+ let state = 'default'
776
817
  try {
777
818
  let captured = false
778
819
  await rec?.beforeStep(node)
779
820
  const capture = async (o = {}) => {
780
821
  captured = true
781
- await rec?.beforeCapture(node)
822
+ if (state === 'default') await rec?.beforeCapture(node)
782
823
  // a named viewport: within this device, or any of the run when it has no devices (as before)
783
- const keys = o.viewport ? pickKeys(scope, group.device ? group.keys : Object.keys(scope.viewports), [o.viewport]) : group.keys
824
+ const keys = stateKeys(scope, o.viewport ? pickKeys(scope, group.device ? group.keys : Object.keys(scope.viewports), [o.viewport]) : group.keys, state)
784
825
  if (!keys.length) throw new UserError(`Unknown viewport "${o.viewport}" for ${group.device || platform}.`)
785
826
  const out = []
786
827
  for (const key of keys) {
@@ -803,14 +844,25 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
803
844
  records.push(...out)
804
845
  return out.length === 1 ? out[0] : out
805
846
  }
806
- if (scripted) {
807
- const mod = await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href)
808
- await mod.default({ app: driver, page: driver.page || null, webview: driver.webview || null, locale, node: { id: node.id, title: localized(node.title, locale), kind: node.kind }, device: group.device, env, run: run.meta, capture, link: (id = node.id) => linkFor(env, stepOf(scenario, id), locale), url: (id = node.id) => linkFor(env, stepOf(scenario, id), locale) })
809
- } else await driver.link(link)
810
- if (!captured) await capture()
847
+ const mod = scripted ? await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href) : null
848
+ const once = async () => {
849
+ captured = false
850
+ if (mod) await mod.default({ app: driver, page: driver.page || null, webview: driver.webview || null, locale, state, node: { id: node.id, title: localized(node.title, locale), kind: node.kind, ...(node.states ? { states: nodeStates(scope, node) } : {}) }, device: group.device, env, run: run.meta, capture, link: (id = node.id) => linkFor(env, stepOf(scenario, id), locale), url: (id = node.id) => linkFor(env, stepOf(scenario, id), locale) })
851
+ else await driver.link(link)
852
+ if (!captured) await capture()
853
+ }
854
+ await once()
811
855
  await rec?.afterStep(node, driver.web ? { records } : { record: records[0] })
812
856
  say(`✓ ${node.id} ${locale}${tag}`)
813
857
  streak.ok(streakKey)
858
+ for (const s of nodeStates(scope, node).slice(1)) {
859
+ state = s; records = []
860
+ try { await once(); say(`✓ ${node.id} ${locale}${tag} · ${s}`) } catch (error) {
861
+ failed.push(`${node.id} (${locale}${tag} · ${s}): ${error.message.split('\n')[0]}`)
862
+ say(`✗ ${node.id} ${locale}${tag} · ${s}: ${error.message.split('\n')[0]}`)
863
+ }
864
+ }
865
+ state = 'default'
814
866
  } catch (error) {
815
867
  await rec?.afterStep(node, driver.web ? { error, records } : { error, record: records[0] }).catch(() => {})
816
868
  failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
@@ -1026,7 +1078,9 @@ const commands = {
1026
1078
  if (scenario.environments && !scenario.environments.includes(env.name)) throw new UserError(`Scenario ${scenario.id} is not allowed on ${env.name} (allowed: ${scenario.environments.join(', ')}).`)
1027
1079
  if (env.readonly && !scenario.readonly) throw new UserError(`Environment ${env.name} is readonly; the scenario must declare readonly: true.`)
1028
1080
  const locales = list(args.locales) || scenario.locales || ['default']
1029
- const scope = { ...startScope({ args, scenario, config, env }), ...(args.note ? { note: args.note } : {}) }
1081
+ // design environments render every mode of their token set (#46): the modes come from the folder's set
1082
+ const tokenModes = env.kind === 'design' ? await designTokenModesOf(config, root, env) : []
1083
+ const scope = { ...startScope({ args, scenario, config, env, tokenModes }), ...(args.note ? { note: args.note } : {}) }
1030
1084
  scope.locales = locales
1031
1085
  const vpNames = baseViewports(scope)
1032
1086
  const git = await gitMeta(root)
@@ -1105,7 +1159,8 @@ const commands = {
1105
1159
  }
1106
1160
  if (isApp(scenario.platform)) {
1107
1161
  // the app's current screen, through the environment's driver (no navigation); --viewport may name a device key
1108
- const key = typeof args.viewport === 'string' ? args.viewport : Object.keys(run.meta.scope.viewports || {})[0]
1162
+ const key0 = typeof args.viewport === 'string' ? args.viewport : Object.keys(run.meta.scope.viewports || {})[0]
1163
+ const key = Object.keys(inState(run, node, { [key0]: run.meta.scope.viewports[key0] }, args.state))[0] || key0
1109
1164
  const device = run.meta.scope.devices?.[variantOf(run.meta.scope, key).viewport]?.app ? variantOf(run.meta.scope, key).viewport : null
1110
1165
  const cfg = deviceConfig(scenario.platform, env.app?.[scenario.platform] || {}, device)
1111
1166
  const driver = driverFor(scenario.platform, { ...env, app: { ...(env.app || {}), [scenario.platform]: cfg } }, { root, scenarioDir: resolve(root, run.meta.scenario_file, '..'), locale })
@@ -1139,7 +1194,7 @@ const commands = {
1139
1194
  return
1140
1195
  }
1141
1196
  const since = (await readCaptures(run.dir)).filter((c) => c.node === node.id).map((c) => c.at).pop()
1142
- const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: manualKeys(run, list(args.viewports)), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, stability: Number(args.stability) || 0 })
1197
+ const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: inState(run, node, manualKeys(run, list(args.viewports)), args.state), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, stability: Number(args.stability) || 0 })
1143
1198
  for (const r of results) {
1144
1199
  const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
1145
1200
  say(`✓ ${node.id} ${locale} ${r.viewport} ${r.size.width}×${r.size.height}px ${failed.length ? `failing checks: ${failed.join(', ')}` : 'checks ok'}${r.blocked_requests.length ? ` blocked: ${r.blocked_requests.length}` : ''}`)
@@ -1197,8 +1252,8 @@ const commands = {
1197
1252
  }
1198
1253
  }
1199
1254
  if (env.kind === 'design') {
1200
- await walkDesign({ config, root, run, scenario, env, pick: list(args.steps), locales: list(args.locales) || run.meta.scope.locales, viewports: scopeViewports(run, list(args.viewports)), say, noCache: !!args['no-cache'] })
1201
- const lint = await lintDesign({ dir: designDir(env, root), scenario })
1255
+ await walkDesign({ config, root, run, scenario, env, pick: list(args.steps), locales: list(args.locales) || run.meta.scope.locales, viewports: scopeKeys(run.meta.scope, engineKeys(run.meta.scope, 'chromium', '*'), list(args.viewports)), say, noCache: !!args['no-cache'] })
1256
+ const lint = await lintDesign({ dir: designDir(env, root), scenario, origins: await lintOrigins(config, env) })
1202
1257
  if (lint.errors || lint.warnings) say(`\nPortable HTML: ${lint.errors} error(s), ${lint.warnings} warning(s); kaloko design lint lists them${lint.errors ? ' (errors stop kaloko sync: they break on the live step)' : ''}.`)
1203
1258
  return
1204
1259
  }
@@ -1341,22 +1396,32 @@ const commands = {
1341
1396
  const scriptFile = resolve(scenarioDir, node.execution.script)
1342
1397
  // one invocation of the step script; the automatic capture runs unless the script captured the step itself:
1343
1398
  // a crop is an attachment (#49), and a capture from an earlier walk must not stand in for this one (#50)
1344
- const rec = await recorderFor(sPage)
1345
- const runStep = async (mod) => {
1399
+ const rec0 = await recorderFor(sPage)
1400
+ // step states (#46): the script runs once per state with `state`; each run captures that state's keys
1401
+ // (the recording follows the default state)
1402
+ const runStep = async (mod, state = 'default') => {
1346
1403
  let captured = false
1404
+ const rec = state === 'default' ? rec0 : null
1347
1405
  rec?.beforeStep(node)
1348
1406
  const records = []
1349
- const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeKeys(run.meta.scope, pass.keys, opts.viewports || null), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {}, stability: Number(args.stability) || 0 }).then((r) => { prefetch?.add(r, run.dir); records.push(...r); return r }) }
1407
+ const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeKeys(run.meta.scope, stateKeys(run.meta.scope, Object.keys(scopeKeys(run.meta.scope, pass.keys, opts.viewports || null)), state)), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {}, stability: Number(args.stability) || 0 }).then((r) => { prefetch?.add(r, run.dir); records.push(...r); return r }) }
1350
1408
  try {
1351
- await mod.default({ page: sPage, context: sCtx, locale, node: { id: node.id, title: localized(node.title, locale), kind: node.kind }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0 }), acceptDialogs: () => acceptDialogs(sPage) })
1409
+ await mod.default({ page: sPage, context: sCtx, locale, state, node: { id: node.id, title: localized(node.title, locale), kind: node.kind, ...(node.states ? { states: nodeStates(run.meta.scope, node) } : {}) }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0 }), acceptDialogs: () => acceptDialogs(sPage) })
1352
1410
  if (!captured) await capture()
1353
1411
  } catch (error) { await rec?.afterStep(node, { error }); throw error }
1354
1412
  await rec?.afterStep(node, { records })
1355
1413
  }
1356
1414
  try {
1357
- await runStep(await import(pathToFileURL(scriptFile).href))
1415
+ const mod = await import(pathToFileURL(scriptFile).href)
1416
+ await runStep(mod)
1358
1417
  say(`✓ ${node.id} ${locale}${tag}`)
1359
1418
  streak.ok(streakKey)
1419
+ for (const state of nodeStates(run.meta.scope, node).slice(1)) {
1420
+ try { await runStep(mod, state); say(`✓ ${node.id} ${locale}${tag} · ${state}`) } catch (error) {
1421
+ failed.push(`${node.id} (${locale}${tag} · ${state}): ${error.message.split('\n')[0]}`)
1422
+ say(`✗ ${node.id} ${locale}${tag} · ${state}: ${error.message.split('\n')[0]}`)
1423
+ }
1424
+ }
1360
1425
  } catch (error) {
1361
1426
  // a selector the page no longer has: propose one from the page (#13); --heal applies it once the step passes
1362
1427
  const healed = args['no-heal'] ? null : await healStep({ error, page: sPage, scriptFile, root, output: config.output, run, node, locale, apply: !!args.heal, rerun: runStep }).catch(() => null)
@@ -1796,7 +1861,7 @@ const commands = {
1796
1861
  const client = service(config)
1797
1862
  if (env.kind === 'design' && !args.pull && !args['no-lint']) {
1798
1863
  // what renders locally may still break on the live step (CSP, sandbox): say so before anything is pushed
1799
- const lint = await lintDesign({ dir: designDir(env, root), scenario })
1864
+ const lint = await lintDesign({ dir: designDir(env, root), scenario, origins: await lintOrigins(config, env, { online: true }) })
1800
1865
  if (lint.errors || lint.warnings) say(formatLint(lint, { max: 15 }) + '\n')
1801
1866
  if (lint.errors) throw new UserError(`${lint.errors} error(s) above break the design on the live step; fix them, or sync with --no-lint to push anyway.`)
1802
1867
  }
@@ -1805,19 +1870,19 @@ const commands = {
1805
1870
 
1806
1871
  async design (args) {
1807
1872
  if (args._[0] === 'import') return designImport(args)
1808
- if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--json]\n kaloko design import figma <file-url|key|export folder> [--frames a,b] [--map frames.json] [--token-env FIGMA_TOKEN] [--publish]')
1873
+ if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--offline] [--json]\n kaloko design import figma <file-url|key|export folder> [--frames a,b] [--map frames.json] [--token-env FIGMA_TOKEN] [--publish]')
1809
1874
  const { config, root } = await context(args)
1810
1875
  let run = null
1811
1876
  try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
1812
1877
  let dir = typeof args.dir === 'string' ? resolve(args.dir) : null
1878
+ const runEnv = run ? environment(config, run.meta.environment.name) : null
1879
+ const env = typeof args.env === 'string' ? environment(config, args.env) : runEnv?.kind === 'design' ? runEnv : Object.keys(config.environments || {}).map((n) => environment(config, n)).find((e) => e.kind === 'design')
1813
1880
  if (!dir) {
1814
- const runEnv = run ? environment(config, run.meta.environment.name) : null
1815
- const env = typeof args.env === 'string' ? environment(config, args.env) : runEnv?.kind === 'design' ? runEnv : Object.keys(config.environments).map((n) => environment(config, n)).find((e) => e.kind === 'design')
1816
1881
  if (!env) throw new UserError('No design environment: pass --dir <folder> or --env <design env>.')
1817
1882
  dir = designDir(env, root)
1818
1883
  }
1819
1884
  const scenario = typeof args.scenario === 'string' && /\.ya?ml$/.test(args.scenario) ? await loadScenario(resolve(args.scenario)) : run ? await loadScenario(resolve(root, run.meta.scenario_file)).catch(() => null) : null
1820
- const report = await lintDesign({ dir, scenario })
1885
+ const report = await lintDesign({ dir, scenario, origins: env?.kind === 'design' ? await lintOrigins(config, env, { online: !args.offline }) : null })
1821
1886
  say(args.json ? JSON.stringify(report, null, 2) : formatLint(report, { all: !!args.all, max: args.all ? 500 : 60 }))
1822
1887
  if (report.errors || (args.strict && report.warnings)) process.exitCode = 1
1823
1888
  },