kaloko 0.3.0 → 0.4.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/README.md +18 -0
- package/package.json +1 -1
- package/skills/kaloko/SKILL.md +10 -4
- package/skills/kaloko/reference/design.md +31 -1
- package/skills/kaloko/reference/portable-html.md +92 -0
- package/src/cli.mjs +48 -3
- package/src/lib/build.mjs +4 -1
- package/src/lib/config.mjs +8 -2
- package/src/lib/design-export.mjs +150 -0
- package/src/lib/design-lint.mjs +189 -0
- package/src/lib/design.mjs +7 -0
- package/src/lib/zip.mjs +58 -0
- package/viewer/index.html +114 -12
package/README.md
CHANGED
|
@@ -75,11 +75,29 @@ it (pixels, structure, tokens, contrast). See `skills/kaloko/reference/design.md
|
|
|
75
75
|
|
|
76
76
|
```bash
|
|
77
77
|
npx kaloko start --scenario qa/flows/checkout.yml --env design
|
|
78
|
+
npx kaloko design lint # portable HTML: works on the canvas, from disk and in other tools
|
|
78
79
|
npx kaloko walk && npx kaloko sync # only changed steps render and travel
|
|
79
80
|
npx kaloko share --name "Checkout v2" # a version of the whole flow
|
|
80
81
|
npx kaloko reference --design --version 2 # after the team accepted it
|
|
81
82
|
```
|
|
82
83
|
|
|
84
|
+
On the canvas, *Prototype* (or `P`) presents the steps full screen as live pages: click through links between
|
|
85
|
+
steps, switch the device, walk with the arrow keys, and share a link that opens straight into a step. The rules
|
|
86
|
+
for pages that work everywhere are in `skills/kaloko/reference/portable-html.md`.
|
|
87
|
+
|
|
88
|
+
## Your data is yours: formats
|
|
89
|
+
|
|
90
|
+
Nothing in Kaloko is stored in a format only Kaloko reads. Every export opens from disk without Kaloko:
|
|
91
|
+
|
|
92
|
+
| Export | Command | Formats |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| Design version | `npx kaloko export --design [--version n] --out v2.zip` | HTML, CSS, JS, fonts and images in the design folder layout; `tokens.json` (W3C DTCG); `scenario.yml` (`qawalk.scenario.v1`); screenshots (WebP); `index.html` start page |
|
|
95
|
+
| Live files (handoff) | `npx kaloko export --live [--version n] --out design/` | the design folder as written |
|
|
96
|
+
| Shared run | `npx kaloko export --run <id>` | zip with `run.json` (`qawalk.run.v1`), screenshots and HTML snapshots, live HTML of design steps, comments and verdicts, and `index.html`: the canvas, offline |
|
|
97
|
+
| Documentation captures | `npx kaloko export --scenario <id>` | newest image and HTML of every step, `index.json` |
|
|
98
|
+
|
|
99
|
+
The schemas of these formats ship with the package (`schemas/`).
|
|
100
|
+
|
|
83
101
|
## Configuration and secrets
|
|
84
102
|
|
|
85
103
|
Project configuration is `kaloko.config.yml` in the repository root:
|
package/package.json
CHANGED
package/skills/kaloko/SKILL.md
CHANGED
|
@@ -31,9 +31,10 @@ the canvas, storage and collaboration are the service.
|
|
|
31
31
|
the work belongs to, and you read `kaloko feedback` and `kaloko inbox`
|
|
32
32
|
before continuing on that task.
|
|
33
33
|
- Changes to intent are written back to `ACCEPTANCE.md` (change log row).
|
|
34
|
-
- On a design environment: the folder follows the bundle rules
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
- On a design environment: the folder follows the bundle rules and
|
|
35
|
+
`kaloko design lint` shows no error, `kaloko walk` and `kaloko sync` ran,
|
|
36
|
+
`kaloko status` shows no conflict, and you read the draft's feedback; a
|
|
37
|
+
version is published only when the person asked.
|
|
37
38
|
|
|
38
39
|
## You may do without asking
|
|
39
40
|
|
|
@@ -84,7 +85,12 @@ they can do; never write secrets into the config, a scenario or a script.
|
|
|
84
85
|
[reference/documentation.md](reference/documentation.md)
|
|
85
86
|
- Designing in a design environment (bundle, sync and publish, comments,
|
|
86
87
|
references, handoff, tokens, the design pack, canvas edits, branches, HTML
|
|
87
|
-
view, bringing your own design skill):
|
|
88
|
+
view, presenting the prototype, exports, bringing your own design skill):
|
|
89
|
+
[reference/design.md](reference/design.md)
|
|
90
|
+
- Writing design pages that work on the live step, from disk and in other
|
|
91
|
+
tools (relative URLs, no CDNs, state in the URL, deterministic content,
|
|
92
|
+
links between steps; what `kaloko design lint` checks):
|
|
93
|
+
[reference/portable-html.md](reference/portable-html.md)
|
|
88
94
|
- Command reference: `kaloko help`; when something does not work (Chrome,
|
|
89
95
|
token, environment URL, evaluator key), run `kaloko doctor` first and fix
|
|
90
96
|
what it marks ✕.
|
|
@@ -37,9 +37,14 @@ 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
|
-
-
|
|
40
|
+
- Links between steps are plain relative links to the page: `<a href="../payment/index.html">`. The canvas
|
|
41
|
+
follows them; `data-kaloko-go="payment"` (or `data-qawalk-go`) moves to that step whatever the `href` says.
|
|
41
42
|
- Reference token values as CSS custom properties (`var(--color-accent)`), so the inspector and the token
|
|
42
43
|
check see where values come from.
|
|
44
|
+
- Write pages by the portable HTML rules ([portable-html.md](portable-html.md)): relative URLs only, bundled
|
|
45
|
+
fonts, no CDNs, state in the URL instead of storage, deterministic content, viewport meta and `lang`.
|
|
46
|
+
`kaloko design lint` checks the folder; `kaloko sync` runs it first and stops on errors (things that break on
|
|
47
|
+
the live step), warnings are printed.
|
|
43
48
|
|
|
44
49
|
## Sync and publish
|
|
45
50
|
|
|
@@ -62,6 +67,19 @@ design/
|
|
|
62
67
|
4. Change what the comments ask, sync again, then answer in the comment thread (MCP `add_draft_comment`).
|
|
63
68
|
5. When the person is happy, `kaloko share --name "…"` publishes a version.
|
|
64
69
|
|
|
70
|
+
## Present the prototype
|
|
71
|
+
|
|
72
|
+
The canvas plays design steps full screen: *Prototype* in the top bar or the step detail, or `P`. Each step runs as
|
|
73
|
+
its live page in a device frame (phone for mobile viewports, a window for desktop; switchable), clicks on links and
|
|
74
|
+
`data-kaloko-go` move between steps, `←` `→` walk the flow, `Esc` leaves. The address (`#present=<step>&vp=mobile`)
|
|
75
|
+
opens straight into the presentation of that step: share it with the person who reviews.
|
|
76
|
+
|
|
77
|
+
- Live pages need the user-content origin of the service; where it is not available (a self-hosted instance
|
|
78
|
+
without it, a step never rendered live) the presentation shows the screenshots.
|
|
79
|
+
- From the local preview and an offline export, the steps open from the run's merged folder (`live/site/`), so plain
|
|
80
|
+
relative links click through there too.
|
|
81
|
+
- A link's query and hash travel to the next step (`../payment/index.html?plan=pro#card`): keep prototype state there.
|
|
82
|
+
|
|
65
83
|
## References
|
|
66
84
|
|
|
67
85
|
- The design reference is an accepted version: `kaloko reference --design --version 4` (a person accepts the
|
|
@@ -78,6 +96,18 @@ Development builds from the design reference:
|
|
|
78
96
|
page structure and open comments; `list_step_changes` names the steps that changed since the last reference.
|
|
79
97
|
- Translate to the project's stack with the project's own skills; keep the step ids and the token names.
|
|
80
98
|
|
|
99
|
+
## Your data is yours
|
|
100
|
+
|
|
101
|
+
Everything exports in open formats, readable without Kaloko:
|
|
102
|
+
|
|
103
|
+
| What | Command | Contents |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| a design version (or `--draft`, default the design reference) | `kaloko export --design [--version n] --out dir/` or `--out v4.zip` | the live files in the folder layout, `tokens.json` (DTCG), `scenario.yml` (`qawalk.scenario.v1`), `screenshots/<step>/<locale>/<viewport>.webp`, `kaloko.json` (what is where) and `index.html`, a start page that opens from disk |
|
|
106
|
+
| the live files only (the handoff) | `kaloko export --live [--version n] --out design/` | the design folder as the designer wrote it |
|
|
107
|
+
| a shared run | `kaloko export --run <id>` | `run.json`, every capture, the live HTML of design steps (per revision and merged in `live/site/`), comments and verdicts, and `index.html`: the canvas, offline from `file://` |
|
|
108
|
+
|
|
109
|
+
When someone asks for the design or the evidence outside Kaloko, export; do not screenshot the canvas.
|
|
110
|
+
|
|
81
111
|
## Tokens
|
|
82
112
|
|
|
83
113
|
- The set is W3C DTCG. Both value styles are read: strings (`"#2E6BFF"`, `"16px"`) and the current format's
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Portable interactive HTML
|
|
2
|
+
|
|
3
|
+
Design steps are live pages. The same files have to work in three places:
|
|
4
|
+
|
|
5
|
+
- **on the live origin** of the canvas (`<revision>.kaloko-usercontent.com`): a sandboxed frame with an opaque origin and
|
|
6
|
+
`Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-src 'none';
|
|
7
|
+
worker-src 'none'; form-action 'none'` (no popups, no modals, no service workers);
|
|
8
|
+
- **from disk** (`file://`): the local preview, `kaloko export --design`, and anyone who unzips an export;
|
|
9
|
+
- **in another tool**: the export is the handoff and the way out of Kaloko.
|
|
10
|
+
|
|
11
|
+
`kaloko design lint` checks a design folder against the rules below; `kaloko sync` runs it first and stops on errors
|
|
12
|
+
(`--no-lint` skips it, `kaloko walk` prints a summary). Errors break for certain on the live step; warnings make the
|
|
13
|
+
prototype fragile or the captures unstable. `kaloko design lint --all` also lists information, `--strict` fails on
|
|
14
|
+
warnings, `--json` gives every finding with file, line, rule and fix.
|
|
15
|
+
|
|
16
|
+
## Files
|
|
17
|
+
|
|
18
|
+
- Everything a page needs is in the design folder, referenced with **relative paths** (`../_shared/app.css`). No
|
|
19
|
+
`https://`, no protocol-relative `//cdn…`, no paths starting with `/` (on the live origin `/` is the origin root, from
|
|
20
|
+
disk it is the disk root). Errors: `external-url`, `root-relative`, `outside-folder`, `missing-file`.
|
|
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.
|
|
23
|
+
- Only bundle file types (html, css, js, mjs, json, svg, woff2, woff, png, jpg, webp, avif, gif, mp4, webm, txt).
|
|
24
|
+
Convert TTF/OTF fonts and other formats (`file-type`).
|
|
25
|
+
- **Size budget:** a file over 1 MB or a step over 5 MB (with the shared files) warns (`large-file`); 10 MB per file
|
|
26
|
+
and 40 MB per step are the limits (`too-large`). Images as webp/avif at the size shown.
|
|
27
|
+
|
|
28
|
+
## Links between steps
|
|
29
|
+
|
|
30
|
+
- Link steps with **plain relative links to the page itself**: `<a href="../payment/index.html">`, from a locale page
|
|
31
|
+
`../../payment/cs/index.html`. A link to a folder (`../payment/`) opens nothing from disk or a zip
|
|
32
|
+
(`directory-link`); a link to a page that does not exist warns (`broken-link`).
|
|
33
|
+
- The canvas follows such links in the presentation and the Live view, and `data-kaloko-go="payment"` (or the older
|
|
34
|
+
`data-qawalk-go`) moves to a step whatever the `href` says. Keep a real `href` next to it so the page also works
|
|
35
|
+
from disk.
|
|
36
|
+
- No `target="_blank"`, `window.open`, `top.location` (`new-window`, `top-navigation`): the frame may not open
|
|
37
|
+
windows or navigate the canvas.
|
|
38
|
+
- Forms do not submit (`form-action 'none'`): handle `submit` in a script (`event.preventDefault()`) and go on with a
|
|
39
|
+
link or `data-kaloko-go` (`form-action`).
|
|
40
|
+
|
|
41
|
+
## State and data
|
|
42
|
+
|
|
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
|
|
45
|
+
(`?plan=pro#card`): a link's query and hash travel to the next step in the presentation, and work from disk.
|
|
46
|
+
Wrap any storage access you keep in `try/catch` with a fallback.
|
|
47
|
+
- **No network:** real APIs, `XMLHttpRequest`, `WebSocket`, `EventSource` do not connect (`websocket`). Put data in
|
|
48
|
+
JSON fixtures of the bundle and `fetch('./fixtures/cart.json')`.
|
|
49
|
+
- **No code from strings:** `eval`, `new Function`, `setTimeout('…')` are blocked (`eval`). No service workers
|
|
50
|
+
(`service-worker`), no iframes, `object` or `embed` (`embedded-frame`). `alert`, `confirm` and `prompt` are blocked
|
|
51
|
+
(`modal`): use an in-page `<dialog>`.
|
|
52
|
+
|
|
53
|
+
## Deterministic content
|
|
54
|
+
|
|
55
|
+
Captures are cached by content and compared in pixels. A page that changes on every render makes unchanged steps look
|
|
56
|
+
changed (`nondeterministic`):
|
|
57
|
+
|
|
58
|
+
- fixed dates (`new Date('2026-01-15T10:00')`), no `Date.now()` or `new Date()` for visible text;
|
|
59
|
+
- fake data from a seeded generator, not `Math.random()` or `crypto.randomUUID()`;
|
|
60
|
+
- animations that end, or none at the moment of capture; content that does not depend on load timing.
|
|
61
|
+
|
|
62
|
+
## Document basics
|
|
63
|
+
|
|
64
|
+
Every page starts like this (`doctype`, `charset`, `viewport-meta`, `lang`, `title`):
|
|
65
|
+
|
|
66
|
+
```html
|
|
67
|
+
<!doctype html>
|
|
68
|
+
<html lang="en">
|
|
69
|
+
<head>
|
|
70
|
+
<meta charset="utf-8">
|
|
71
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
72
|
+
<title>Payment · Checkout</title>
|
|
73
|
+
<link rel="stylesheet" href="../_shared/app.css">
|
|
74
|
+
</head>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- Images have `alt` (`alt=""` for decoration) (`img-alt`); real buttons, labels for fields, one `<h1>`, landmarks.
|
|
78
|
+
- Respect `prefers-reduced-motion` when anything animates (`reduced-motion`):
|
|
79
|
+
`@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important } }`
|
|
80
|
+
- The mobile viewports render at their real width: the viewport meta and fluid layout matter.
|
|
81
|
+
|
|
82
|
+
## Tokens
|
|
83
|
+
|
|
84
|
+
- `tokens.json` (W3C DTCG) in the folder root; values as **CSS custom properties** in the shared CSS
|
|
85
|
+
(`--color-accent: #2e6bff`) and used with `var(--color-accent)`, so the inspector names them and `kaloko tokens check`
|
|
86
|
+
sees raw values (`tokens-unused`, `no-tokens`).
|
|
87
|
+
- Name custom properties after the token path with dots as dashes (`color.accent` → `--color-accent`).
|
|
88
|
+
|
|
89
|
+
## Done means
|
|
90
|
+
|
|
91
|
+
`kaloko design lint` shows no error and no warning you cannot explain, the pages open from the folder by
|
|
92
|
+
double-click and click through, and `kaloko walk` renders the same in every viewport.
|
package/src/cli.mjs
CHANGED
|
@@ -32,6 +32,8 @@ import { htmlViewAllowed } from './lib/htmlview.mjs'
|
|
|
32
32
|
import { service } from './lib/service.mjs'
|
|
33
33
|
import { walkDesign, syncDesign, publishVersion, historyCmd, referenceCmd, tokensCmd, exportLive, localRevisions, branchCmd, uploadBlobs } from './lib/design-cli.mjs'
|
|
34
34
|
import { designDir, readBase, headKey } from './lib/design.mjs'
|
|
35
|
+
import { lintDesign, formatLint } from './lib/design-lint.mjs'
|
|
36
|
+
import { exportDesign } from './lib/design-export.mjs'
|
|
35
37
|
import { driverFor, isApp, pngSize, ocr as ocrImage } from './lib/apps/drivers.mjs'
|
|
36
38
|
import { captureApp } from './lib/apps/capture.mjs'
|
|
37
39
|
import { readTree, flatten } from './lib/apps/tree.mjs'
|
|
@@ -121,7 +123,8 @@ Results
|
|
|
121
123
|
Semantic evaluator vs human labels: precision/recall per criterion at the
|
|
122
124
|
configured thresholds and suggested thresholds (labels from a golden file or
|
|
123
125
|
from reviewers' criterion verdicts in the service)
|
|
124
|
-
export [--run id] [--out file.zip] Download a shared run
|
|
126
|
+
export [--run id] [--out file.zip] Download a shared run as a zip before it expires: run.json, every file, the live
|
|
127
|
+
HTML of design steps and index.html, the canvas that opens offline from disk
|
|
125
128
|
export --scenario <id> [--accepted] [--out dir] [--locales l] [--viewports v]
|
|
126
129
|
Newest capture of every step as steps/<step>/<locale>/<viewport>.<ext> plus
|
|
127
130
|
index.json (documentation layer, Business plan); unchanged files are skipped
|
|
@@ -134,8 +137,12 @@ Design (environments with kind: design; live HTML steps in a folder)
|
|
|
134
137
|
start --scenario <file> --env <design env> A run of the design folder (same scenario as development and QA)
|
|
135
138
|
walk [--steps a,b] [--no-cache] Render the steps; unchanged revisions reuse their captures
|
|
136
139
|
status Per step: synced, changed here, changed in the draft, conflict; step status
|
|
137
|
-
sync [--pull|--push] [--branch b] [--mine a,b|--theirs a,b]
|
|
140
|
+
sync [--pull|--push] [--branch b] [--mine a,b|--theirs a,b] [--no-lint]
|
|
138
141
|
Exchange revisions with the shared draft; only changed steps and missing files travel
|
|
142
|
+
Lints the folder first: errors (things that break on the live step) stop the push
|
|
143
|
+
design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--json]
|
|
144
|
+
Check the folder against the portable HTML rules (relative URLs, no CDNs, no storage,
|
|
145
|
+
deterministic content, viewport, lang, links that also work from disk); --strict fails on warnings
|
|
139
146
|
share [--name <text>] [--branch b] Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged"
|
|
140
147
|
history [--scenario id] [--step id] [--locale l] [--json]
|
|
141
148
|
Versions of the flow; revisions of a step with who (person or agent) and when
|
|
@@ -149,6 +156,9 @@ Design (environments with kind: design; live HTML steps in a folder)
|
|
|
149
156
|
Live files of a version (default: the design reference) into a folder: the handoff
|
|
150
157
|
export --live --views --run <id> [--viewport desktop] [--out design/]
|
|
151
158
|
Fork into a draft: the HTML views of an app run become design steps (experimental)
|
|
159
|
+
export --design [--version n|--draft|--run id] [--out dir|file.zip]
|
|
160
|
+
A design version in open formats: HTML + assets, tokens.json (DTCG), scenario.yml,
|
|
161
|
+
screenshots and an index.html that opens from disk (default: the design reference)
|
|
152
162
|
|
|
153
163
|
Common options: --run <id|dir> (or KALOKO_RUN) --config <file> KALOKO_AGENT=<name> labels writes by an agent
|
|
154
164
|
`
|
|
@@ -709,6 +719,8 @@ const commands = {
|
|
|
709
719
|
const { config, root, run, scenario, env } = await runContext(args)
|
|
710
720
|
if (env.kind === 'design') {
|
|
711
721
|
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'] })
|
|
722
|
+
const lint = await lintDesign({ dir: designDir(env, root), scenario })
|
|
723
|
+
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)' : ''}.`)
|
|
712
724
|
return
|
|
713
725
|
}
|
|
714
726
|
if (isApp(scenario.platform)) return walkApp({ config, root, run, scenario, env, args })
|
|
@@ -1026,9 +1038,33 @@ const commands = {
|
|
|
1026
1038
|
async sync (args) {
|
|
1027
1039
|
const { config, root, run, scenario, env } = await runContext(args)
|
|
1028
1040
|
const client = service(config)
|
|
1041
|
+
if (env.kind === 'design' && !args.pull && !args['no-lint']) {
|
|
1042
|
+
// what renders locally may still break on the live step (CSP, sandbox): say so before anything is pushed
|
|
1043
|
+
const lint = await lintDesign({ dir: designDir(env, root), scenario })
|
|
1044
|
+
if (lint.errors || lint.warnings) say(formatLint(lint, { max: 15 }) + '\n')
|
|
1045
|
+
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.`)
|
|
1046
|
+
}
|
|
1029
1047
|
await syncDesign({ config, root, run, scenario, env, client, args, say, push: !args.pull, pull: !args.push, render: (steps) => walkDesign({ config, root, run, scenario, env, pick: steps, locales: run.meta.scope.locales, viewports: run.meta.scope.viewports, say }).then(() => evaluateRun({ run, scenario, config, root })) })
|
|
1030
1048
|
},
|
|
1031
1049
|
|
|
1050
|
+
async design (args) {
|
|
1051
|
+
if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--json]')
|
|
1052
|
+
const { config, root } = await context(args)
|
|
1053
|
+
let run = null
|
|
1054
|
+
try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
|
|
1055
|
+
let dir = typeof args.dir === 'string' ? resolve(args.dir) : null
|
|
1056
|
+
if (!dir) {
|
|
1057
|
+
const runEnv = run ? environment(config, run.meta.environment.name) : null
|
|
1058
|
+
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')
|
|
1059
|
+
if (!env) throw new UserError('No design environment: pass --dir <folder> or --env <design env>.')
|
|
1060
|
+
dir = designDir(env, root)
|
|
1061
|
+
}
|
|
1062
|
+
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
|
|
1063
|
+
const report = await lintDesign({ dir, scenario })
|
|
1064
|
+
say(args.json ? JSON.stringify(report, null, 2) : formatLint(report, { all: !!args.all, max: args.all ? 500 : 60 }))
|
|
1065
|
+
if (report.errors || (args.strict && report.warnings)) process.exitCode = 1
|
|
1066
|
+
},
|
|
1067
|
+
|
|
1032
1068
|
async history (args) {
|
|
1033
1069
|
const { config, root } = await context(args)
|
|
1034
1070
|
const scenarioId = await scenarioIdOf(args, config, root)
|
|
@@ -1234,7 +1270,7 @@ const commands = {
|
|
|
1234
1270
|
const d = compareRuns(run, base)
|
|
1235
1271
|
if (args.json) { say(JSON.stringify(d, null, 2)); return }
|
|
1236
1272
|
say(formatComparison(d))
|
|
1237
|
-
if (config.share?.url && run.org) say(`\nCanvas: ${config
|
|
1273
|
+
if (config.share?.url && run.org) { const { serviceUrl } = await import('./lib/service.mjs'); say(`\nCanvas: ${serviceUrl(config)}/${run.org}/runs/${run.id}/#cmp=${base.id}`) }
|
|
1238
1274
|
},
|
|
1239
1275
|
|
|
1240
1276
|
async draft (args) {
|
|
@@ -1290,6 +1326,15 @@ const commands = {
|
|
|
1290
1326
|
async export (args) {
|
|
1291
1327
|
const { config, root } = await context(args)
|
|
1292
1328
|
const client = service(config)
|
|
1329
|
+
if (args.design) {
|
|
1330
|
+
// open formats only: the version as a folder (or zip) that opens from disk without Kaloko
|
|
1331
|
+
const scenarioId = await scenarioIdOf(args, config, root)
|
|
1332
|
+
const v = typeof args.version === 'string' ? args.version : typeof args.design === 'string' ? args.design : null
|
|
1333
|
+
const ref = args.draft ? 'draft' : v ? `v${v.replace(/^v/, '')}` : typeof args.run === 'string' ? args.run : 'design'
|
|
1334
|
+
const out = typeof args.out === 'string' ? resolve(args.out) : resolve(root, config.output, 'design-export', `${scenarioId}-${ref}`)
|
|
1335
|
+
await exportDesign({ config, scenarioId, client, ref, branch: typeof args.branch === 'string' ? args.branch : 'main', out, say })
|
|
1336
|
+
return
|
|
1337
|
+
}
|
|
1293
1338
|
if (args.live) {
|
|
1294
1339
|
const scenarioId = await scenarioIdOf(args, config, root)
|
|
1295
1340
|
let out = typeof args.out === 'string' ? resolve(args.out) : null
|
package/src/lib/build.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parsePlan, coverage, flaky } from './coverage.mjs'
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
3
4
|
import { join, resolve } from 'node:path'
|
|
4
5
|
import { readJson, writeJson } from './util.mjs'
|
|
5
6
|
import { readCaptures, runPaths, listRuns } from './run.mjs'
|
|
@@ -140,7 +141,9 @@ export async function writePreview ({ run, manifest }) {
|
|
|
140
141
|
for (const [l, vps] of Object.entries(n.captures)) { captures[l] = {}; for (const [vp, c] of Object.entries(vps)) captures[l][vp] = c?.tree ? { ...c, tree_inline: await readJson(join(run.dir, c.tree), null) } : c }
|
|
141
142
|
return { ...n, captures }
|
|
142
143
|
}
|
|
143
|
-
|
|
144
|
+
// design runs: live steps open from the merged tree, where links between steps resolve (design.mjs copyLive)
|
|
145
|
+
const site = existsSync(join(run.dir, 'live', 'site')) ? { live_site: 'live/site/' } : {}
|
|
146
|
+
const preview = { ...manifest, ...site, nodes: await Promise.all(manifest.nodes.map(async (n) => (n.stack ? { ...n, stack: { ...n.stack, items_inline: (await readJson(join(run.dir, n.stack.items), { items: [] })).items } } : withTrees(n)))) }
|
|
144
147
|
const json = JSON.stringify(preview).replace(/</g, '\\u003c')
|
|
145
148
|
const html = template.replace('/*__QAWALK_RUN__*/null', () => json)
|
|
146
149
|
await writeJson(runPaths(run.dir).manifest, manifest)
|
package/src/lib/config.mjs
CHANGED
|
@@ -9,9 +9,13 @@ export const CONFIG_FILE = 'kaloko.config.yml'
|
|
|
9
9
|
/** The file name before the rename to Kaloko; still found, so existing projects keep working. */
|
|
10
10
|
export const LEGACY_CONFIG_FILE = 'qawalk.config.yml'
|
|
11
11
|
|
|
12
|
-
/** Environment variables were QAWALK_
|
|
12
|
+
/** Environment variables were QAWALK_*: either name fills the other when that one is not set, so new .env files work
|
|
13
|
+
* with configs that still name QAWALK_TOKEN (share.token_env) and old .env files work with the new defaults. */
|
|
13
14
|
export function aliasLegacyEnv (vars = process.env) {
|
|
14
|
-
for (const [k, v] of Object.entries(vars))
|
|
15
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
16
|
+
const m = k.match(/^(QAWALK|KALOKO)_(.+)$/)
|
|
17
|
+
if (m && vars[`${m[1] === 'QAWALK' ? 'KALOKO' : 'QAWALK'}_${m[2]}`] == null) vars[`${m[1] === 'QAWALK' ? 'KALOKO' : 'QAWALK'}_${m[2]}`] = v
|
|
18
|
+
}
|
|
15
19
|
}
|
|
16
20
|
aliasLegacyEnv()
|
|
17
21
|
|
|
@@ -113,5 +117,7 @@ export async function loadEnvFiles (files) {
|
|
|
113
117
|
if (!m || process.env[m[1]] !== undefined) continue
|
|
114
118
|
process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2')
|
|
115
119
|
}
|
|
120
|
+
// per file, so a project .env with a former name still wins over a user-level file with the new one
|
|
121
|
+
aliasLegacyEnv()
|
|
116
122
|
}
|
|
117
123
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Exportability (#42): a design version leaves Kaloko in open formats only, as a folder or a zip that opens from disk:
|
|
2
|
+
// the live HTML with its assets (the design folder layout), tokens.json (W3C DTCG), scenario.yml (qawalk.scenario.v1),
|
|
3
|
+
// the screenshots, kaloko.json (what is where) and index.html, a start page without scripts.
|
|
4
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { createWriteStream } from 'node:fs'
|
|
6
|
+
import { Readable } from 'node:stream'
|
|
7
|
+
import { pipeline } from 'node:stream/promises'
|
|
8
|
+
import { gunzipSync } from 'node:zlib'
|
|
9
|
+
import { join, dirname, relative, basename } from 'node:path'
|
|
10
|
+
import yaml from 'js-yaml'
|
|
11
|
+
import { zipStream } from './zip.mjs'
|
|
12
|
+
import { UserError } from './util.mjs'
|
|
13
|
+
import { TOKENS_FILE } from './design-core.mjs'
|
|
14
|
+
|
|
15
|
+
export const DESIGN_EXPORT_SCHEMA = 'qawalk.design-export.v1'
|
|
16
|
+
const enc = encodeURIComponent
|
|
17
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
18
|
+
const titleOf = (t, locale) => (t && typeof t === 'object' ? t[locale] ?? t.default ?? Object.values(t)[0] : t) || ''
|
|
19
|
+
const flowOrder = (nodes) => nodes.filter((n) => n.kind !== 'decision').slice().sort((a, b) => (a.col ?? 0) - (b.col ?? 0) || (a.lane ?? 0) - (b.lane ?? 0))
|
|
20
|
+
|
|
21
|
+
/** A qawalk.scenario.v1 document from a run manifest: a design version carries the whole flow (steps, criteria, edges). */
|
|
22
|
+
export function scenarioFromManifest (m) {
|
|
23
|
+
const lanes = m.lanes || []
|
|
24
|
+
const nodes = m.nodes.map((n) => {
|
|
25
|
+
const own = (n.criteria || []).filter((c) => !c.pack)
|
|
26
|
+
const packs = [...new Set((n.criteria || []).filter((c) => c.pack).map((c) => c.pack))]
|
|
27
|
+
return {
|
|
28
|
+
id: n.id, kind: n.kind, title: n.title,
|
|
29
|
+
...(n.path != null ? { path: n.path } : {}), ...(n.purpose ? { purpose: n.purpose } : {}),
|
|
30
|
+
...(lanes.length && Number.isInteger(n.lane) ? { lane: n.lane } : {}), ...(n.session ? { session: n.session } : {}),
|
|
31
|
+
// run manifests do not carry how a step is reached; the scenario needs it, so the export names the manual way
|
|
32
|
+
...(['decision', 'stack'].includes(n.kind) ? {} : { execution: n.execution?.strategy && n.execution?.instructions ? { strategy: n.execution.strategy, instructions: n.execution.instructions } : { strategy: 'manual', instructions: `Open ${typeof n.path === 'string' ? n.path : `the ${n.id} step`}.` } }),
|
|
33
|
+
...(own.length ? { criteria: own.map((c) => ({ id: c.id, text: c.text, check: c.check })) } : {}), ...(packs.length ? { packs } : {})
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
return {
|
|
37
|
+
schema: 'qawalk.scenario.v1', id: m.scenario.id, title: m.scenario.title || m.scenario.id, kind: m.scenario.kind === 'flow' ? 'flow' : 'task',
|
|
38
|
+
...(m.project?.slug ? { project: m.project.slug } : {}),
|
|
39
|
+
locales: m.scope.locales, viewports: m.scope.viewports, ...(lanes.length ? { lanes } : {}), ...(m.sessions ? { sessions: m.sessions } : {}),
|
|
40
|
+
nodes, edges: (m.edges || []).map((e) => ({ from: e.from, to: e.to, ...(e.label ? { label: e.label } : {}), ...(e.kind && e.kind !== 'main' ? { kind: e.kind } : {}) }))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Start page of an exported design: the steps in flow order with each locale's page and screenshot; plain HTML, no script. */
|
|
45
|
+
export function exportIndexHtml ({ index }) {
|
|
46
|
+
const loc0 = index.locales[0]
|
|
47
|
+
const rows = index.steps.map((s) => `<li><h2>${esc(titleOf(s.title, loc0))} <small>${esc(s.id)}</small></h2>${s.purpose ? `<p>${esc(s.purpose)}</p>` : ''}<p>${index.locales.filter((l) => s.pages[l]).map((l) => `<a href="${esc(s.pages[l].split('/').map(enc).join('/'))}">${esc(l)}: ${esc(titleOf(s.title, l))}</a>`).join(' · ') || '<em>no page</em>'}</p>${s.screenshots.length ? `<p class="shots">${s.screenshots.map((x) => `<a href="${esc(x.file)}"><img src="${esc(x.file)}" alt="${esc(`${s.id} ${x.locale} ${x.viewport}`)}" loading="lazy" width="160"></a>`).join('')}</p>` : ''}</li>`).join('\n')
|
|
48
|
+
return `<!doctype html>
|
|
49
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(index.scenario.title)} · ${esc(index.ref)}</title>
|
|
50
|
+
<style>:root{color-scheme:light dark;--ink:#151514;--paper:#fff;--line:#ddd}@media (prefers-color-scheme:dark){:root{--ink:#eee;--paper:#151514;--line:#333}}body{margin:0;font:16px/1.5 system-ui,sans-serif;color:var(--ink);background:var(--paper)}main{max-width:960px;margin:0 auto;padding:32px 16px}ol{padding-left:20px}li{border-top:1px solid var(--line);padding:12px 0}h2{font-size:18px;margin:0}small{font-weight:400;opacity:.6}.shots img{border:1px solid var(--line);border-radius:4px;margin:0 8px 8px 0;height:auto}code{font-size:14px}</style></head>
|
|
51
|
+
<body><main><h1>${esc(index.scenario.title)}</h1>
|
|
52
|
+
<p>${esc(index.ref)}${index.version ? ` · v${esc(index.version)}${index.version_name ? ` “${esc(index.version_name)}”` : ''}` : ''} · exported ${esc(index.exported_at.slice(0, 10))} from Kaloko. Everything here is plain HTML, CSS, JS and images; it opens from disk without Kaloko.</p>
|
|
53
|
+
<p>Files: <a href="scenario.yml">scenario.yml</a> (steps, criteria, flow)${index.tokens ? ` · <a href="${TOKENS_FILE}">${TOKENS_FILE}</a> (W3C DTCG design tokens)` : ''} · <a href="kaloko.json">kaloko.json</a> (what is where)</p>
|
|
54
|
+
<ol>
|
|
55
|
+
${rows}
|
|
56
|
+
</ol></main></body></html>
|
|
57
|
+
`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A folder or, when `out` ends with .zip, a zip with one top folder; put(path, bytes) then close(). */
|
|
61
|
+
export function exportSink (out) {
|
|
62
|
+
if (!out.endsWith('.zip')) return { put: async (p, bytes) => { const t = join(out, p); await mkdir(dirname(t), { recursive: true }); await writeFile(t, bytes) }, close: async () => {} }
|
|
63
|
+
const top = basename(out, '.zip')
|
|
64
|
+
const entries = []
|
|
65
|
+
return {
|
|
66
|
+
put: async (p, bytes) => { entries.push({ name: `${top}/${p}`, body: bytes instanceof Uint8Array ? bytes : new TextEncoder().encode(bytes) }) },
|
|
67
|
+
close: async () => { await mkdir(dirname(out), { recursive: true }); await pipeline(Readable.fromWeb(zipStream(entries)), createWriteStream(out)) }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Run id behind a reference: design (the design reference, per branch), vN, or a run id. */
|
|
72
|
+
async function runIdOf ({ client, org, scenarioId, ref, branch }) {
|
|
73
|
+
if (ref === 'design') {
|
|
74
|
+
const refs = await client.request('GET', `/orgs/${org}/scenarios/${enc(scenarioId)}/references`)
|
|
75
|
+
const id = refs.design_by_branch?.[branch] || refs.design
|
|
76
|
+
if (!id) throw new UserError(`${scenarioId} has no design reference yet; name a version with --version <n> (kaloko history lists them).`)
|
|
77
|
+
return id
|
|
78
|
+
}
|
|
79
|
+
if (/^v\d+$/.test(ref)) {
|
|
80
|
+
const v = (await client.request('GET', `/orgs/${org}/scenarios/${enc(scenarioId)}/versions`)).find((x) => String(x.version) === ref.slice(1))
|
|
81
|
+
if (!v) throw new UserError(`${scenarioId} has no version ${ref}.`)
|
|
82
|
+
return v.id
|
|
83
|
+
}
|
|
84
|
+
return ref
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Exports a design version (or the draft) of a scenario into `out` (folder or .zip). Returns the index (kaloko.json).
|
|
89
|
+
* `ref`: design | vN | draft | a run id.
|
|
90
|
+
*/
|
|
91
|
+
export async function exportDesign ({ config, scenarioId, client, ref = 'design', branch = 'main', out, say = () => {} }) {
|
|
92
|
+
const org = config.org
|
|
93
|
+
const draft = ref === 'draft'
|
|
94
|
+
const runId = draft ? null : await runIdOf({ client, org, scenarioId, ref, branch })
|
|
95
|
+
const manifest = draft
|
|
96
|
+
? await client.request('GET', `/orgs/${org}/scenarios/${enc(scenarioId)}/draft/manifest${branch !== 'main' ? `?branch=${enc(branch)}` : ''}`)
|
|
97
|
+
: await client.request('GET', `/orgs/${org}/runs/${runId}`)
|
|
98
|
+
if (manifest.source !== 'design' && !manifest.draft) throw new UserError(`${runId} is not a design version; kaloko export --run ${runId} exports any run.`)
|
|
99
|
+
const revs = await client.request('GET', `/orgs/${org}/scenarios/${enc(scenarioId)}/revisions?ref=${enc(draft ? (branch === 'main' ? 'draft' : `draft:${branch}`) : runId)}`)
|
|
100
|
+
const sink = exportSink(out)
|
|
101
|
+
const fileOf = async (path) => {
|
|
102
|
+
const res = draft ? await fetch(manifest.files_base + path) : await client.fetch(`/orgs/${org}/runs/${runId}/files/${path.split('/').map(enc).join('/')}`)
|
|
103
|
+
if (!res.ok) throw new UserError(`${path}: HTTP ${res.status}`)
|
|
104
|
+
return Buffer.from(await res.arrayBuffer())
|
|
105
|
+
}
|
|
106
|
+
// the live files keep the design folder layout: links between steps work from disk exactly as in the folder
|
|
107
|
+
const paths = new Map(), clashes = []
|
|
108
|
+
const pages = {}
|
|
109
|
+
for (const s of revs.steps.filter((x) => x.state === 'default')) {
|
|
110
|
+
;(pages[s.node] ||= {})[s.locale] = s.entry
|
|
111
|
+
for (const [p, hash] of Object.entries(s.files)) {
|
|
112
|
+
if (paths.has(p)) { if (paths.get(p).hash !== hash) clashes.push(p); continue }
|
|
113
|
+
paths.set(p, { hash, revision: s.revision })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const [p, { revision }] of paths) {
|
|
117
|
+
const res = await client.fetch(`/orgs/${org}/revisions/${revision}/files/${p.split('/').map(enc).join('/')}`)
|
|
118
|
+
let buf = Buffer.from(await res.arrayBuffer())
|
|
119
|
+
if (res.headers.get('X-QAwalk-Encoding') === 'gzip') buf = gunzipSync(buf)
|
|
120
|
+
await sink.put(p, buf)
|
|
121
|
+
}
|
|
122
|
+
const steps = []
|
|
123
|
+
let shots = 0
|
|
124
|
+
for (const n of flowOrder(manifest.nodes)) {
|
|
125
|
+
const screenshots = []
|
|
126
|
+
for (const [locale, vps] of Object.entries(n.captures || {})) for (const [viewport, c] of Object.entries(vps || {})) {
|
|
127
|
+
const src = c?.image || c?.png
|
|
128
|
+
if (!src) continue
|
|
129
|
+
const file = `screenshots/${n.id}/${locale}/${viewport}.${src.split('.').pop()}`
|
|
130
|
+
await sink.put(file, await fileOf(src))
|
|
131
|
+
screenshots.push({ locale, viewport, file, size: c.size || null })
|
|
132
|
+
shots++
|
|
133
|
+
}
|
|
134
|
+
steps.push({ id: n.id, title: n.title, purpose: n.purpose || '', kind: n.kind, pages: pages[n.id] || {}, screenshots })
|
|
135
|
+
}
|
|
136
|
+
const index = {
|
|
137
|
+
schema: DESIGN_EXPORT_SCHEMA, scenario: { id: scenarioId, title: manifest.scenario?.title || scenarioId }, ref, run_id: runId,
|
|
138
|
+
version: manifest.version ?? null, version_name: manifest.version_name ?? null, branch, exported_at: new Date().toISOString(),
|
|
139
|
+
locales: manifest.scope.locales, viewports: manifest.scope.viewports, tokens: paths.has(TOKENS_FILE) ? TOKENS_FILE : null,
|
|
140
|
+
formats: { pages: 'HTML, CSS, JS and assets in the design folder layout (<step>/index.html, <step>/<locale>/index.html, _shared/)', tokens: 'W3C Design Tokens (DTCG) JSON', scenario: 'qawalk.scenario.v1 YAML', screenshots: 'WebP or PNG per step, locale and viewport' },
|
|
141
|
+
steps
|
|
142
|
+
}
|
|
143
|
+
await sink.put('scenario.yml', `# Exported from Kaloko (${ref}); validate with: kaloko validate scenario.yml\n` + yaml.dump(scenarioFromManifest(manifest), { lineWidth: 140, noRefs: true }))
|
|
144
|
+
await sink.put('kaloko.json', JSON.stringify(index, null, 2) + '\n')
|
|
145
|
+
await sink.put('index.html', exportIndexHtml({ index }))
|
|
146
|
+
await sink.close()
|
|
147
|
+
if (clashes.length) say(`! ${clashes.length} file(s) differ between steps (${clashes.slice(0, 3).join(', ')}); the export keeps the first step's copy.`)
|
|
148
|
+
say(`Design ${scenarioId} at ${ref}${index.version ? ` (v${index.version})` : ''} → ${relative(process.cwd(), out) || '.'}\n ${paths.size} live file(s), ${shots} screenshot(s), ${steps.length} step(s); open index.html from disk.`)
|
|
149
|
+
return index
|
|
150
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Portable interactive HTML (#42): checks a design folder against what works on the live origin
|
|
2
|
+
// (<revision>.<USERCONTENT_HOST>, sandboxed, CSP default-src 'self'), from disk (file://) and from a zip.
|
|
3
|
+
// Errors are things that certainly break there; warnings make prototypes fragile or captures unstable.
|
|
4
|
+
// The rules are explained in skills/kaloko/reference/portable-html.md. lintFiles is pure (tests, other tools).
|
|
5
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
6
|
+
import { join, relative, sep } from 'node:path'
|
|
7
|
+
import { BUNDLE_LIMITS, SHARED_DIR, TOKENS_FILE, TEXT_TYPES, contentTypeOf, extOf, resolveRef } from './design-core.mjs'
|
|
8
|
+
|
|
9
|
+
/** Rule id → level and a short reason (the fix is in each finding). */
|
|
10
|
+
export const LINT_RULES = {
|
|
11
|
+
'external-url': ['error', 'loads a file from another site; the live origin allows only files of the bundle'],
|
|
12
|
+
'root-relative': ['error', 'a path starting with / points outside the design folder on the live origin and from disk'],
|
|
13
|
+
'outside-folder': ['error', 'a relative path that climbs out of the design folder'],
|
|
14
|
+
'missing-file': ['error', 'a referenced file that is not in the folder'],
|
|
15
|
+
'file-type': ['error', 'a referenced file type the bundle does not allow'],
|
|
16
|
+
'eval': ['error', "eval and new Function are blocked (no 'unsafe-eval')"],
|
|
17
|
+
'service-worker': ['error', 'service workers are refused on live steps'],
|
|
18
|
+
'embedded-frame': ['error', 'iframe, object and embed are blocked (frame-src and object-src none)'],
|
|
19
|
+
'websocket': ['error', 'network connections are blocked (connect-src self)'],
|
|
20
|
+
'too-large': ['error', 'over the bundle limits'],
|
|
21
|
+
'tokens-json': ['error', 'tokens.json is not valid JSON'],
|
|
22
|
+
'storage': ['warn', 'storage and cookies throw in the sandboxed frame and are not shared between viewers'],
|
|
23
|
+
'nondeterministic': ['warn', 'random values and the current time change every capture'],
|
|
24
|
+
'directory-link': ['warn', 'a link to a folder does not open its index.html from disk or a zip'],
|
|
25
|
+
'broken-link': ['warn', 'a link to a page that is not in the folder'],
|
|
26
|
+
'new-window': ['warn', 'popups are blocked in the sandbox'],
|
|
27
|
+
'top-navigation': ['warn', 'the frame may not navigate the canvas'],
|
|
28
|
+
'modal': ['warn', 'alert, confirm and prompt are blocked in the sandbox'],
|
|
29
|
+
'form-action': ['warn', 'form submission is blocked (form-action none)'],
|
|
30
|
+
'doctype': ['warn', 'without <!doctype html> the page renders in quirks mode'],
|
|
31
|
+
'charset': ['warn', 'without <meta charset> text may decode wrongly from disk'],
|
|
32
|
+
'viewport-meta': ['warn', 'without the viewport meta, mobile viewports render a zoomed-out desktop page'],
|
|
33
|
+
'lang': ['warn', '<html lang> tells screen readers and the language checks what language the page is in'],
|
|
34
|
+
'title': ['warn', 'the page title names the step in tabs, history and screen readers'],
|
|
35
|
+
'img-alt': ['warn', 'images need an alt text (empty for decoration)'],
|
|
36
|
+
'reduced-motion': ['warn', 'animations should respect prefers-reduced-motion'],
|
|
37
|
+
'large-file': ['warn', 'over the size budget'],
|
|
38
|
+
'tokens-unused': ['warn', 'the token set is there but no CSS custom property uses it'],
|
|
39
|
+
'missing-step': ['warn', 'a scenario step without a design'],
|
|
40
|
+
'external-link': ['info', 'a link that leaves the prototype'],
|
|
41
|
+
'stray-folder': ['info', 'a folder that is not a step of the scenario'],
|
|
42
|
+
'no-tokens': ['info', 'no token set: the inspector cannot name values']
|
|
43
|
+
}
|
|
44
|
+
export const BUDGET = { file_bytes: 1024 * 1024, step_bytes: 5 * 1024 * 1024 }
|
|
45
|
+
|
|
46
|
+
const lineAt = (text, i) => { let n = 1; for (let k = 0; k < i && k < text.length; k++) if (text.charCodeAt(k) === 10) n++; return n }
|
|
47
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:/i
|
|
48
|
+
const stripQ = (u) => u.split('#')[0].split('?')[0]
|
|
49
|
+
const kb = (b) => `${Math.round(b / 1024)} kB`
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Lints the files of a design folder. `files`: Map path → { bytes, text? } (text for text types), `steps`: step ids of
|
|
53
|
+
* the scenario (null = every top-level folder but _shared). Returns findings { rule, level, file, line, message, fix }.
|
|
54
|
+
*/
|
|
55
|
+
export function lintFiles (files, { steps = null } = {}) {
|
|
56
|
+
const out = []
|
|
57
|
+
const add = (rule, file, line, message, fix = '') => out.push({ rule, level: LINT_RULES[rule][0], file, line: line || null, message, fix })
|
|
58
|
+
const has = (p) => files.has(p)
|
|
59
|
+
const isDir = (p) => [...files.keys()].some((f) => f.startsWith(p.replace(/\/?$/, '/')))
|
|
60
|
+
let anyMotion = null, reducedMotion = false, usesVars = false
|
|
61
|
+
const resource = (file, text, i, raw, { script = false, data = false } = {}) => {
|
|
62
|
+
const u = raw.trim()
|
|
63
|
+
if (!u || u.startsWith('#') || /^(about:blank|blob:)/i.test(u)) return
|
|
64
|
+
const line = lineAt(text, i)
|
|
65
|
+
if (/^data:/i.test(u)) { if (script) add('external-url', file, line, 'a script from a data: URL is blocked (script-src self)', 'Put the script into a .js file of the bundle.'); return }
|
|
66
|
+
if (/^javascript:/i.test(u)) return
|
|
67
|
+
if (/^(https?:)?\/\//i.test(u) || SCHEME.test(u)) return add('external-url', file, line, `loads ${u.slice(0, 120)} from another site; it will not load on the live step (CSP default-src 'self')`, data ? 'Live steps have no network: put the data into a JSON fixture of the bundle and fetch(\'./fixtures/….json\').' : 'Download the file into _shared/ (fonts as woff2) and reference it with a relative path.')
|
|
68
|
+
if (u.startsWith('/')) return add('root-relative', file, line, `${u.slice(0, 120)} starts with /, which is the origin root on the live step and the disk root from file://`, `Use a path relative to ${file} (for example ../${SHARED_DIR}/…).`)
|
|
69
|
+
const p = resolveRef(file, stripQ(u))
|
|
70
|
+
if (!p) return add('outside-folder', file, line, `${u.slice(0, 120)} points outside the design folder`, 'Keep every file the page needs inside the design folder.')
|
|
71
|
+
if (!contentTypeOf(p)) return add('file-type', file, line, `${p}: .${extOf(p) || '?'} files are not part of a bundle`, 'Convert it (fonts to woff2, images to webp/png/svg) or drop it.')
|
|
72
|
+
if (!has(p)) add('missing-file', file, line, `${u.slice(0, 120)} → ${p} is not in the folder`, 'Add the file or fix the path (paths are case-sensitive on the service).')
|
|
73
|
+
}
|
|
74
|
+
const link = (file, text, i, raw, attrs) => {
|
|
75
|
+
const u = raw.trim(); const line = lineAt(text, i)
|
|
76
|
+
const tg = attrs.match(/\starget\s*=\s*["']?_(blank|top|parent)/i)
|
|
77
|
+
if (tg) add('new-window', file, line, `a link with target=_${tg[1]} does nothing in the sandboxed live step`, 'Drop the target attribute; links between steps open in the same frame.')
|
|
78
|
+
if (!u || u.startsWith('#') || /^(mailto|tel|javascript):/i.test(u)) return
|
|
79
|
+
if (/^(https?:)?\/\//i.test(u)) return add('external-link', file, line, `${u.slice(0, 120)} leaves the prototype`, '')
|
|
80
|
+
if (u.startsWith('/')) return add('root-relative', file, line, `link ${u.slice(0, 120)} starts with /: it 404s on the live step and from disk`, 'Link relatively, e.g. ../payment/index.html.')
|
|
81
|
+
const bare = stripQ(u)
|
|
82
|
+
if (!bare) return
|
|
83
|
+
const p = resolveRef(file, bare)
|
|
84
|
+
if (!p) return add('outside-folder', file, line, `link ${u.slice(0, 120)} points outside the design folder`, '')
|
|
85
|
+
if (bare.endsWith('/') || (!extOf(p) && isDir(p))) return add('directory-link', file, line, `link ${u.slice(0, 120)} points at a folder; from disk or a zip the browser shows a listing or nothing`, `Link the page itself: ${bare.replace(/\/?$/, '/')}index.html`)
|
|
86
|
+
if (!has(p)) add('broken-link', file, line, `link ${u.slice(0, 120)} → ${p} is not in the folder`, 'Point it at a step page (../<step>/index.html) or add the page.')
|
|
87
|
+
}
|
|
88
|
+
const css = (file, text, base = 0, whole = text) => {
|
|
89
|
+
for (const m of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi)) resource(file, whole, base + m.index, m[2])
|
|
90
|
+
for (const m of text.matchAll(/@import\s+(['"])([^'"]+)\1/gi)) resource(file, whole, base + m.index, m[2])
|
|
91
|
+
if (/var\(--/.test(text)) usesVars = true
|
|
92
|
+
if (/prefers-reduced-motion/.test(text)) reducedMotion = true
|
|
93
|
+
if (!anyMotion) { const m = text.match(/(?:^|[;{\s])(animation(?:-name)?|transition)\s*:\s*(?!none\b)[^;}]*[1-9]/m); if (m) anyMotion = { file, line: lineAt(whole, base + m.index) } }
|
|
94
|
+
}
|
|
95
|
+
const js = (file, text, base = 0, whole = text) => {
|
|
96
|
+
const at = (re, fn) => { for (const m of text.matchAll(re)) fn(m, lineAt(whole, base + m.index)) }
|
|
97
|
+
at(/(?:^|[^.\w$])(?:import|export)\s[^'"]*?from\s*(['"])([^'"]+)\1|import\s*\(\s*(['"])([^'"]+)\3\s*\)/g, (m) => { const u = m[2] || m[4]; if (/^(\.|\/|https?:|\/\/)/.test(u)) resource(file, whole, base + m.index, u, { script: true }) })
|
|
98
|
+
at(/\bfetch\(\s*(['"`])([^'"`$]+)\1/g, (m) => resource(file, whole, base + m.index, m[2], { data: true }))
|
|
99
|
+
at(/\bnew\s+(?:XMLHttpRequest|EventSource|WebSocket)\b/g, (m, l) => add('websocket', file, l, `${m[0].replace(/^new\s+/, '')} needs a network the live step does not have (connect-src self)`, 'Read fixtures from JSON files in the bundle with fetch(\'./fixtures/…json\').'))
|
|
100
|
+
at(/(?:^|[^.\w$])(?:eval\s*\(|new\s+Function\s*\(|set(?:Timeout|Interval)\s*\(\s*['"`])/g, (m, l) => add('eval', file, l, 'code from a string is blocked on the live step (no \'unsafe-eval\')', 'Pass a function instead of a string; do not use eval or new Function.'))
|
|
101
|
+
at(/navigator\.serviceWorker/g, (m, l) => add('service-worker', file, l, 'service workers are refused on live steps and outlive the page', 'Remove the registration; prototypes need no offline cache.'))
|
|
102
|
+
at(/\b(localStorage|sessionStorage|indexedDB|document\.cookie)\b/g, (m, l) => add('storage', file, l, `${m[1]} throws in the sandboxed live step (opaque origin) and is empty for every other viewer`, 'Keep prototype state in the URL (location.hash or ?query) or in memory; wrap any storage access in try/catch.'))
|
|
103
|
+
at(/\b(Date\.now\(\)|new Date\(\s*\)|Math\.random\(\)|crypto\.(?:randomUUID|getRandomValues)\()/g, (m, l) => add('nondeterministic', file, l, `${m[1].replace(/\($/, '()')} makes every capture different, so unchanged steps look changed`, 'Use fixed dates (new Date(\'2026-01-15T10:00\')) and a seeded generator for fake data.'))
|
|
104
|
+
at(/(?:^|[^.\w$]|window\.)(alert|confirm|prompt)\s*\(/g, (m, l) => add('modal', file, l, `${m[1]}() is blocked in the sandbox (no allow-modals)`, 'Show an in-page <dialog> instead.'))
|
|
105
|
+
at(/(?:^|[^.\w$])window\.open\s*\(/g, (m, l) => add('new-window', file, l, 'window.open is blocked in the sandbox (no allow-popups)', 'Navigate in the same frame (location.href = \'../next/index.html\').'))
|
|
106
|
+
at(/\b(?:top|parent)\.location\b/g, (m, l) => add('top-navigation', file, l, `${m[0]} tries to navigate the canvas`, 'Navigate the frame itself, or use data-kaloko-go="<step>".'))
|
|
107
|
+
}
|
|
108
|
+
for (const [file, f] of files) {
|
|
109
|
+
if (f.bytes > BUNDLE_LIMITS.max_file_bytes) add('too-large', file, null, `${kb(f.bytes)}: over the ${Math.round(BUNDLE_LIMITS.max_file_bytes / 1048576)} MB limit of one file`, 'Compress it or split it.')
|
|
110
|
+
else if (f.bytes > BUDGET.file_bytes) add('large-file', file, null, `${kb(f.bytes)}: over the ${kb(BUDGET.file_bytes)} budget of one file`, /\.(png|jpe?g|gif)$/i.test(file) ? 'Use webp or avif at the size it is shown.' : /\.(woff2?)$/i.test(file) ? 'Subset the font to the characters you use.' : 'Keep prototypes light: every viewer loads it.')
|
|
111
|
+
if (f.text == null) continue
|
|
112
|
+
const t = f.text, ext = extOf(file)
|
|
113
|
+
if (ext === 'css') css(file, t)
|
|
114
|
+
else if (ext === 'js' || ext === 'mjs') js(file, t)
|
|
115
|
+
else if (ext === 'html' || ext === 'svg') {
|
|
116
|
+
for (const m of t.matchAll(/\s(src|poster|data-src)\s*=\s*(['"])([^'"]*)\2/gi)) resource(file, t, m.index, m[3], { script: /<script\b[^>]*$/i.test(t.slice(Math.max(0, m.index - 200), m.index)) })
|
|
117
|
+
for (const m of t.matchAll(/<link\b([^>]*)>/gi)) { const h = m[1].match(/\shref\s*=\s*(['"])([^'"]*)\1/i); const rel = (m[1].match(/\srel\s*=\s*(['"]?)([^'"\s>]+)\1/i)?.[2] || '').toLowerCase(); if (h && !/^(canonical|alternate|author|help|license|next|prev|search|dns-prefetch|preconnect)$/.test(rel)) resource(file, t, m.index, h[2]) }
|
|
118
|
+
for (const m of t.matchAll(/<(?:use|image|feImage)\b[^>]*\s(?:xlink:)?href\s*=\s*(['"])([^'"]*)\1/gi)) resource(file, t, m.index, m[2])
|
|
119
|
+
for (const m of t.matchAll(/\ssrcset\s*=\s*(['"])([^'"]*)\1/gi)) for (const part of m[2].split(',')) resource(file, t, m.index, part.trim().split(/\s+/)[0])
|
|
120
|
+
for (const m of t.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) css(file, m[1], m.index + m[0].indexOf(m[1]), t)
|
|
121
|
+
for (const m of t.matchAll(/\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) css(file, m[1] ?? m[2], m.index, t)
|
|
122
|
+
for (const m of t.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) if (!/\ssrc\s*=/.test(m[1]) && !/type\s*=\s*["']?(application\/(ld\+)?json|importmap|text\/template)/i.test(m[1])) js(file, m[2], m.index + m[0].indexOf(m[2]), t)
|
|
123
|
+
if (ext === 'svg') continue
|
|
124
|
+
for (const m of t.matchAll(/<(a|area)\b([^>]*)>/gi)) { const h = m[2].match(/\shref\s*=\s*(['"])([^'"]*)\1/i); if (h) link(file, t, m.index, h[2], m[2]) }
|
|
125
|
+
for (const m of t.matchAll(/<(iframe|object|embed|frame)\b/gi)) add('embedded-frame', file, lineAt(t, m.index), `<${m[1].toLowerCase()}> is blocked on the live step`, 'Draw the embedded content into the page (an image or markup of it).')
|
|
126
|
+
for (const m of t.matchAll(/<form\b[^>]*\saction\s*=\s*(['"])([^'"]*)\1/gi)) if (m[2] && m[2] !== '#') add('form-action', file, lineAt(t, m.index), `submitting to ${m[2].slice(0, 80)} is blocked on the live step`, 'Handle submit in a script (event.preventDefault()) and move on with a link or data-kaloko-go.')
|
|
127
|
+
for (const m of t.matchAll(/<img\b([^>]*)>/gi)) if (!/\salt\s*=/i.test(m[1])) add('img-alt', file, lineAt(t, m.index), 'an <img> without alt', 'Add alt="…" (alt="" for decoration).')
|
|
128
|
+
if (!/^\s*(<!--[\s\S]*?-->\s*)*<!doctype html/i.test(t)) add('doctype', file, 1, 'no <!doctype html>', 'Start the page with <!doctype html>.')
|
|
129
|
+
if (!/<meta\s+charset\s*=/i.test(t) && !/<meta[^>]+content\s*=\s*["'][^"']*charset=/i.test(t)) add('charset', file, null, 'no <meta charset>', 'Add <meta charset="utf-8"> first in <head>.')
|
|
130
|
+
if (!/<meta[^>]+name\s*=\s*["']?viewport/i.test(t)) add('viewport-meta', file, null, 'no viewport meta', 'Add <meta name="viewport" content="width=device-width, initial-scale=1">.')
|
|
131
|
+
if (!/<html\b[^>]*\slang\s*=\s*["']?[a-z]/i.test(t)) add('lang', file, null, '<html> has no lang', 'Add <html lang="en"> (the locale of the page).')
|
|
132
|
+
if (!/<title>\s*[^<\s]/i.test(t)) add('title', file, null, 'no <title>', 'Name the step in <title>.')
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (anyMotion && !reducedMotion) add('reduced-motion', anyMotion.file, anyMotion.line, 'animations or transitions without a prefers-reduced-motion rule anywhere in the folder', 'Add @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important } } to the shared CSS.')
|
|
136
|
+
if (files.has(TOKENS_FILE)) {
|
|
137
|
+
try { JSON.parse(files.get(TOKENS_FILE).text) } catch (e) { add('tokens-json', TOKENS_FILE, null, e.message, 'Fix the JSON (kaloko tokens validate).') }
|
|
138
|
+
if (!usesVars) add('tokens-unused', TOKENS_FILE, null, 'no var(--…) in any stylesheet', 'Write token values as CSS custom properties (--color-accent) and use var(--color-accent), so the inspector and kaloko tokens check see them.')
|
|
139
|
+
} else add('no-tokens', '.', null, 'no tokens.json in the folder', 'Add a W3C DTCG token set (kaloko tokens pull, or write tokens.json).')
|
|
140
|
+
// per step: everything under its folder plus the shared files (an upper bound of what a revision carries)
|
|
141
|
+
const tops = [...new Set([...files.keys()].filter((p) => p.includes('/')).map((p) => p.split('/')[0]))].filter((d) => d !== SHARED_DIR)
|
|
142
|
+
const shared = [...files].filter(([p]) => p.startsWith(`${SHARED_DIR}/`)).reduce((s, [, f]) => s + f.bytes, 0)
|
|
143
|
+
for (const d of tops) {
|
|
144
|
+
const own = [...files].filter(([p]) => p.startsWith(`${d}/`)).reduce((s, [, f]) => s + f.bytes, 0)
|
|
145
|
+
if (own + shared > BUNDLE_LIMITS.max_revision_bytes) add('too-large', `${d}/`, null, `${kb(own + shared)} with the shared files: over the ${Math.round(BUNDLE_LIMITS.max_revision_bytes / 1048576)} MB limit of a step`, 'Move big media out or compress it.')
|
|
146
|
+
else if (own + shared > BUDGET.step_bytes) add('large-file', `${d}/`, null, `${kb(own + shared)} with the shared files: over the ${kb(BUDGET.step_bytes)} budget of a step`, 'Compress images and fonts; drop unused files from _shared/.')
|
|
147
|
+
if (steps && !steps.includes(d)) add('stray-folder', `${d}/`, null, `${d}/ is not a step of the scenario`, 'Rename it to a step id, or move shared files into _shared/.')
|
|
148
|
+
}
|
|
149
|
+
if (steps) for (const s of steps) if (!has(`${s}/index.html`) && ![...files.keys()].some((p) => p.startsWith(`${s}/`) && p.endsWith('/index.html'))) add('missing-step', `${s}/`, null, `step ${s} has no ${s}/index.html`, `Add ${s}/index.html.`)
|
|
150
|
+
const order = { error: 0, warn: 1, info: 2 }
|
|
151
|
+
return out.sort((a, b) => order[a.level] - order[b.level] || a.file.localeCompare(b.file) || (a.line || 0) - (b.line || 0))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Reads a design folder (hidden files and .qawalk/ left out) into the shape lintFiles takes. */
|
|
155
|
+
export async function readDesignFolder (dir) {
|
|
156
|
+
const files = new Map()
|
|
157
|
+
const walk = async (d) => {
|
|
158
|
+
let entries = []
|
|
159
|
+
try { entries = await readdir(d, { withFileTypes: true }) } catch { return }
|
|
160
|
+
for (const e of entries) {
|
|
161
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
162
|
+
const full = join(d, e.name)
|
|
163
|
+
if (e.isDirectory()) { await walk(full); continue }
|
|
164
|
+
const path = relative(dir, full).split(sep).join('/')
|
|
165
|
+
const bytes = (await stat(full)).size
|
|
166
|
+
files.set(path, { bytes, ...(TEXT_TYPES.has(extOf(path)) && bytes <= BUNDLE_LIMITS.max_file_bytes ? { text: await readFile(full, 'utf8') } : {}) })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
await walk(dir)
|
|
170
|
+
return files
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Lints a design folder; `scenario` (optional) names the steps. */
|
|
174
|
+
export async function lintDesign ({ dir, scenario = null }) {
|
|
175
|
+
const files = await readDesignFolder(dir)
|
|
176
|
+
const steps = scenario ? scenario.nodes.filter((n) => n.kind !== 'decision' && n.kind !== 'external').map((n) => n.id) : null
|
|
177
|
+
const findings = lintFiles(files, { steps })
|
|
178
|
+
return { dir, files: files.size, findings, errors: findings.filter((f) => f.level === 'error').length, warnings: findings.filter((f) => f.level === 'warn').length }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Human report: errors and warnings (info with `all`), grouped like a compiler, at most `max` lines. */
|
|
182
|
+
export function formatLint (report, { all = false, max = 40, cwd = process.cwd() } = {}) {
|
|
183
|
+
const shown = report.findings.filter((f) => all || f.level !== 'info')
|
|
184
|
+
const where = relative(cwd, report.dir) || '.'
|
|
185
|
+
if (!shown.length) return `✓ ${where}: ${report.files} file(s) follow the portable HTML rules.`
|
|
186
|
+
const mark = { error: '✕', warn: '!', info: '·' }
|
|
187
|
+
const lines = shown.slice(0, max).map((f) => ` ${mark[f.level]} ${f.file}${f.line ? `:${f.line}` : ''} ${f.message} [${f.rule}]${f.fix ? `\n fix: ${f.fix}` : ''}`)
|
|
188
|
+
return `${where}: ${report.errors} error(s), ${report.warnings} warning(s)\n${lines.join('\n')}${shown.length > max ? `\n … ${shown.length - max} more (kaloko design lint --json lists them all)` : ''}`
|
|
189
|
+
}
|
package/src/lib/design.mjs
CHANGED
|
@@ -115,6 +115,13 @@ export async function copyLive (runDir, dir, rev) {
|
|
|
115
115
|
await mkdir(dirname(to), { recursive: true })
|
|
116
116
|
await copyFile(join(dir, path), to)
|
|
117
117
|
}
|
|
118
|
+
// and one merged tree of the run (live/site/<path>, the design folder layout): plain links between steps work from
|
|
119
|
+
// disk there, so the local preview and an offline export present the prototype like the live origin does
|
|
120
|
+
for (const path of Object.keys(rev.files)) {
|
|
121
|
+
const to = join(runDir, 'live', 'site', path)
|
|
122
|
+
await mkdir(dirname(to), { recursive: true })
|
|
123
|
+
await copyFile(join(dir, path), to)
|
|
124
|
+
}
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
// ---- render cache: a capture is reused when the same revision was rendered in the same viewport ----
|
package/src/lib/zip.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Streaming ZIP writer (method "store", data descriptors) for run and design exports, shared by the service and the CLI.
|
|
2
|
+
// Files are appended as they are read from R2; nothing is buffered beyond one chunk.
|
|
3
|
+
|
|
4
|
+
const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0 } return t })()
|
|
5
|
+
export function crc32 (crc, bytes) { crc = ~crc; for (let i = 0; i < bytes.length; i++) crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); return ~crc >>> 0 }
|
|
6
|
+
|
|
7
|
+
const enc = new TextEncoder()
|
|
8
|
+
const u16 = (v, out, o) => { out[o] = v & 0xff; out[o + 1] = (v >>> 8) & 0xff }
|
|
9
|
+
const u32 = (v, out, o) => { out[o] = v & 0xff; out[o + 1] = (v >>> 8) & 0xff; out[o + 2] = (v >>> 16) & 0xff; out[o + 3] = (v >>> 24) & 0xff }
|
|
10
|
+
const dosTime = (d) => ({ time: (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | (d.getUTCSeconds() >> 1), date: ((d.getUTCFullYear() - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate() })
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {Array<{ name: string, body: ReadableStream|Uint8Array|string, modified?: Date }>} entries lazily opened: `body` may be a function returning a promise of the stream
|
|
14
|
+
* @returns {ReadableStream<Uint8Array>}
|
|
15
|
+
*/
|
|
16
|
+
export function zipStream (entries) {
|
|
17
|
+
const central = []
|
|
18
|
+
let offset = 0
|
|
19
|
+
let i = 0
|
|
20
|
+
const now = new Date()
|
|
21
|
+
return new ReadableStream({
|
|
22
|
+
async pull (controller) {
|
|
23
|
+
const push = (bytes) => { controller.enqueue(bytes); offset += bytes.length }
|
|
24
|
+
if (i < entries.length) {
|
|
25
|
+
const e = entries[i++]
|
|
26
|
+
const name = enc.encode(e.name)
|
|
27
|
+
const { time, date } = dosTime(e.modified || now)
|
|
28
|
+
const local = new Uint8Array(30 + name.length)
|
|
29
|
+
u32(0x04034b50, local, 0); u16(20, local, 4); u16(0x0808, local, 6) /* bit 3: sizes in the descriptor, bit 11: UTF-8 */; u16(0, local, 8); u16(time, local, 10); u16(date, local, 12)
|
|
30
|
+
u32(0, local, 14); u32(0, local, 18); u32(0, local, 22); u16(name.length, local, 26); u16(0, local, 28); local.set(name, 30)
|
|
31
|
+
const start = offset
|
|
32
|
+
push(local)
|
|
33
|
+
let body = typeof e.body === 'function' ? await e.body() : e.body
|
|
34
|
+
let crc = 0, size = 0
|
|
35
|
+
if (typeof body === 'string') body = enc.encode(body)
|
|
36
|
+
if (body instanceof Uint8Array) { crc = crc32(crc, body); size = body.length; push(body) } else if (body) {
|
|
37
|
+
const reader = body.getReader()
|
|
38
|
+
for (;;) { const { done, value } = await reader.read(); if (done) break; crc = crc32(crc, value); size += value.length; push(value) }
|
|
39
|
+
}
|
|
40
|
+
const desc = new Uint8Array(16); u32(0x08074b50, desc, 0); u32(crc, desc, 4); u32(size, desc, 8); u32(size, desc, 12); push(desc)
|
|
41
|
+
central.push({ name, crc, size, time, date, start })
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
// central directory
|
|
45
|
+
const cdStart = offset
|
|
46
|
+
for (const c of central) {
|
|
47
|
+
const h = new Uint8Array(46 + c.name.length)
|
|
48
|
+
u32(0x02014b50, h, 0); u16(20, h, 4); u16(20, h, 6); u16(0x0808, h, 8); u16(0, h, 10); u16(c.time, h, 12); u16(c.date, h, 14)
|
|
49
|
+
u32(c.crc, h, 16); u32(c.size, h, 20); u32(c.size, h, 24); u16(c.name.length, h, 28); u16(0, h, 30); u16(0, h, 32); u16(0, h, 34); u16(0, h, 36); u32(0, h, 38); u32(c.start, h, 42); h.set(c.name, 46)
|
|
50
|
+
push(h)
|
|
51
|
+
}
|
|
52
|
+
const end = new Uint8Array(22)
|
|
53
|
+
u32(0x06054b50, end, 0); u16(0, end, 4); u16(0, end, 6); u16(central.length, end, 8); u16(central.length, end, 10); u32(offset - cdStart, end, 12); u32(cdStart, end, 16); u16(0, end, 20)
|
|
54
|
+
push(end)
|
|
55
|
+
controller.close()
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
}
|
package/viewer/index.html
CHANGED
|
@@ -77,7 +77,7 @@ svg.i{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.7;stro
|
|
|
77
77
|
|
|
78
78
|
@media (min-width:821px){.bar{flex-wrap:nowrap}.picker{flex:0 1 auto;min-width:0}.task{min-width:0}.task b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bar > *{flex-shrink:0}.bar > .picker{flex:0 1 auto;min-width:0;max-width:clamp(200px,30vw,640px);overflow:hidden}.bar > .spacer{flex:0 0 0}.task span{display:block;overflow:hidden;text-overflow:ellipsis}}
|
|
79
79
|
@media (max-width:1680px){.bar .ctl .seg-label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}.bar{gap:8px 12px}.picker kbd{display:none}}
|
|
80
|
-
@media (max-width:1400px) and (min-width:1101px){.bar{gap:6px 8px}#viewBtn span[data-t]{display:none}#viewBtn{padding:5px 8px}.bar > .picker{max-width:clamp(140px,18vw,400px)}#rpBtn span{display:none}#rpBtn{padding:5px 8px}#cmpSel{max-width:130px}.appnav a{padding:4px 6px}.brand small{display:none}.seg button{padding:3px 8px}.bar .btn,.seg button,.appnav{font-size:12px}}
|
|
80
|
+
@media (max-width:1400px) and (min-width:1101px){.bar{gap:6px 8px}#viewBtn span[data-t]{display:none}#viewBtn{padding:5px 8px}.bar > .picker{max-width:clamp(140px,18vw,400px)}#rpBtn span,#ptBtn span{display:none}#rpBtn,#ptBtn{padding:5px 8px}#cmpSel{max-width:130px}.appnav a{padding:4px 6px}.brand small{display:none}.seg button{padding:3px 8px}.bar .btn,.seg button,.appnav{font-size:12px}}
|
|
81
81
|
.appnav{display:flex;align-items:center;gap:4px;font-size:13px}
|
|
82
82
|
.appnav a{padding:4px 9px;border-radius:6px;text-decoration:none;color:var(--muted)}
|
|
83
83
|
.appnav a:hover{background:var(--panel-2);color:var(--ink)}
|
|
@@ -236,9 +236,9 @@ table.runs{border-collapse:collapse;width:100%;font-size:12.5px}
|
|
|
236
236
|
@media (prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}}
|
|
237
237
|
body.embed .helpwrap{display:none}
|
|
238
238
|
.bar-break{display:none}
|
|
239
|
-
@media (max-width:820px){.bar{flex-wrap:wrap;gap:6px 8px;padding:8px 10px}.bar > *{order:1}.bar-break{display:block;order:2;flex-basis:100%;height:0}.bar > .picker,#rpBtn,#modeSeg,#locSeg,#dbar{order:3}.bar > .picker{flex:1 1 0;min-width:0;max-width:none}.task b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task span{display:none!important}.bar .spacer{flex:1 1 auto;order:1}.picker .kchip{display:none}}
|
|
239
|
+
@media (max-width:820px){.bar{flex-wrap:wrap;gap:6px 8px;padding:8px 10px}.bar > *{order:1}.bar-break{display:block;order:2;flex-basis:100%;height:0}.bar > .picker,#rpBtn,#ptBtn,#modeSeg,#locSeg,#dbar{order:3}.bar > .picker{flex:1 1 0;min-width:0;max-width:none}.task b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task span{display:none!important}.bar .spacer{flex:1 1 auto;order:1}.picker .kchip{display:none}}
|
|
240
240
|
.legend-in{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line);font-size:12.5px;color:var(--muted)}.legend-in span{display:inline-flex;gap:5px;align-items:center}
|
|
241
|
-
@media (max-width:1100px){.bar .ctl .seg-label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}#viewBtn span[data-t],#rpBtn span{display:none}.picker kbd{display:none}.bar > .picker{max-width:clamp(140px,40vw,420px)}}
|
|
241
|
+
@media (max-width:1100px){.bar .ctl .seg-label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}#viewBtn span[data-t],#rpBtn span,#ptBtn span{display:none}.picker kbd{display:none}.bar > .picker{max-width:clamp(140px,40vw,420px)}}
|
|
242
242
|
/* canvas */
|
|
243
243
|
.viewport{position:relative;overflow:hidden;cursor:grab;touch-action:none;
|
|
244
244
|
background-color:var(--ground);background-image:radial-gradient(var(--dots) 1px,transparent 1.2px);background-size:22px 22px}
|
|
@@ -534,6 +534,26 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
534
534
|
.lb__stage{min-width:0}.lb__live{margin:0 auto;background:#fff;border:1px solid var(--line);border-radius:6px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08)}
|
|
535
535
|
.lb__live iframe{display:block;border:0;background:#fff}
|
|
536
536
|
.lb__cmp .lb__live{margin:0}
|
|
537
|
+
/* prototype presentation: full screen, one step at a time, click-through in the live page */
|
|
538
|
+
.pt{position:fixed;inset:0;z-index:30;display:grid;grid-template-rows:auto 1fr auto;color-scheme:dark;--panel:#1f1f1d;--panel-2:#262624;--ink:#f0f0ec;--muted:#9d9d96;--line:#35352f;background:#111110;color:var(--ink)}
|
|
539
|
+
.pt__bar{display:flex;align-items:center;gap:8px 12px;flex-wrap:wrap;padding:8px 14px;background:#161615;border-bottom:1px solid var(--line)}
|
|
540
|
+
.pt__bar--foot{border-bottom:0;border-top:1px solid var(--line);justify-content:center}
|
|
541
|
+
.pt__bar .spacer{flex:1}.pt__title{font-weight:600;font-size:15px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40vw}
|
|
542
|
+
.pt__stage{position:relative;overflow:hidden;display:grid;place-items:center;min-height:0;padding:16px}
|
|
543
|
+
.pt__fit{position:relative}
|
|
544
|
+
.pt__dev{position:absolute;left:0;top:0;transform-origin:0 0;background:#000;box-shadow:0 20px 60px rgba(0,0,0,.5)}
|
|
545
|
+
.pt__dev--phone{border-radius:44px;padding:14px;border:1px solid #3a3a36}
|
|
546
|
+
.pt__dev--phone .pt__screen{border-radius:32px}
|
|
547
|
+
.pt__dev--desk{border-radius:10px;padding:30px 0 0;border:1px solid #3a3a36}
|
|
548
|
+
.pt__dev--desk::before{content:"";position:absolute;left:14px;top:11px;width:44px;height:8px;background:radial-gradient(circle at 4px 4px,#e4572e 3.5px,transparent 4px),radial-gradient(circle at 22px 4px,#f0a83c 3.5px,transparent 4px),radial-gradient(circle at 40px 4px,#52c784 3.5px,transparent 4px)}
|
|
549
|
+
.pt__dev--bare{padding:0;border-radius:0;border:0}.pt__dev--bare::before{display:none}.pt__dev--bare .pt__screen{border-radius:0}
|
|
550
|
+
.pt__screen{overflow:auto;background:#fff}
|
|
551
|
+
.pt__screen iframe{display:block;border:0;background:#fff}
|
|
552
|
+
.pt__screen img{display:block;width:100%;height:auto}
|
|
553
|
+
.pt__empty{color:var(--muted);text-align:center;padding:40px}
|
|
554
|
+
.pt__bar .note{color:var(--muted)}
|
|
555
|
+
@media (max-width:700px){.pt__title{max-width:60vw}.pt__bar{padding:6px 10px}.pt__stage{padding:6px}}
|
|
556
|
+
@media (prefers-reduced-motion:no-preference){.pt__dev{transition:transform .15s ease-out}}
|
|
537
557
|
.insp{font:12px/1.4 var(--mono)}
|
|
538
558
|
.insp table{width:100%;border-collapse:collapse}.insp td{padding:3px 4px;border-bottom:1px solid var(--line);vertical-align:top}
|
|
539
559
|
.insp td:first-child{color:var(--muted);white-space:nowrap}
|
|
@@ -571,6 +591,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
571
591
|
<kbd>K</kbd>
|
|
572
592
|
</button>
|
|
573
593
|
<button class="btn" id="rpBtn" title="R"><svg class="i" viewBox="0 0 24 24"><path d="M7 5v14l11-7z"/></svg><span data-t="replay"></span></button>
|
|
594
|
+
<button class="btn" id="ptBtn" title="P" hidden><svg class="i" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="13" rx="1.5"/><path d="M8 21h8M12 17v4"/></svg><span data-t="prototype"></span></button>
|
|
574
595
|
<div class="dbar" id="dbar" hidden></div>
|
|
575
596
|
<div class="spacer"></div>
|
|
576
597
|
<div class="seg" id="modeSeg" aria-label="Layout"></div>
|
|
@@ -637,6 +658,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
637
658
|
</div>
|
|
638
659
|
</div>
|
|
639
660
|
<div class="rp" id="rp" hidden role="dialog" aria-modal="true" aria-label="Replay"></div>
|
|
661
|
+
<div class="pt" id="pt" hidden role="dialog" aria-modal="true" aria-label="Prototype"></div>
|
|
640
662
|
<div class="toast" id="toast" hidden></div>
|
|
641
663
|
<script>
|
|
642
664
|
/* Kaloko viewer. Data source:
|
|
@@ -646,7 +668,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
646
668
|
const EMBEDDED = /*__QAWALK_RUN__*/null;
|
|
647
669
|
if (!window.QAWALK_APP) { const el = document.getElementById('qawalk-app'); if (el) { try { window.QAWALK_APP = JSON.parse(el.textContent); } catch {} } }
|
|
648
670
|
const I18N = {
|
|
649
|
-
cs: { makePublicVersion:'Zveřejnit verzi', draft:'Koncept', liveShared:'živě, sdíleno', publishHint:'Publikovat verzi uloží celé flow jako %V; nezměněné kroky se znovu použijí.', htmlView:'HTML pohled', htmlViewNote:'Statická kopie stránky: bez skriptů zákazníka, s přenesenými styly a obrázky, bez tajných údajů.', editTitle:'Upravit v plátně', editText:'Text', editKeep:'ponechat', editSave:'Uložit úpravu', editSaved:'Úprava uložena jako nová revize', editRebased:'Krok se mezitím změnil; úprava je na nejnovější revizi', editUnapplied:'Neuplatněno (prvek už neexistuje)', staleCapture:'Snímek je z předchozí revize; Živě ukazuje poslední úpravu.', branches:'Větve', mergeBranch:'Sloučit do main', mergeConflict:'Krok se změnil v obou větvích:', mergeTakeBranch:'Vzít z větve? (Zrušit = ponechat main)', branchMerged:'Sloučeno', acceptVersion:'Přijmout verzi', returnVersion:'Vrátit verzi', versionVerdict:'Verdikt verze', draftInfo:'Tento koncept je živý a sdílený: všichni, kdo ho mají otevřený, vidí změny hned, i změny agentů. Kroky ukládá kaloko sync nebo úprava v plátně; tlačítko Publikovat verzi uloží celé flow jako novou verzi.', changedSince:'Změněno od', noChangeSince:'Beze změny od', designEnv:'design', version:'Verze', versions:'Verze flow', publish:'Publikovat verzi', publishName:'Název verze (nepovinný)', published:'Publikováno', designRef:'reference designu', setDesignRef:'Nastavit jako referenci designu', unsetDesignRef:'Zrušit referenci designu', designRefHint:'Implementace se kontroluje proti referenci designu (pack design).', openDraft:'Otevřít koncept', liveMode:'Živě', shotMode:'Snímek', inspect:'Inspektor', pinComment:'Komentář k prvku', pickHint:'Klikněte na prvek v náhledu.', pinnedOn:'K prvku', unchangedSince:'beze změny od', unchangedSteps:'kroků beze změny', changedOnly:'Jen změněné', stepStatus:'Stav kroku', st_in_progress:'rozpracováno', st_ready_for_review:'k revizi', st_ready_for_dev:'připraveno pro vývoj', present:'Prezentovat', presenting:'Prezentujete', stopPresent:'Ukončit prezentaci', updatedSteps:'upravil', viaAgent:'přes', here:'tady', copyCss:'Kopírovat CSS', cssCopied:'CSS zkopírováno', noToken:'mimo sadu tokenů', tokenSet:'token', revisionBy:'revize', noLiveOrigin:'Živé kroky nejsou na této instanci zapnuté.', draftEmpty:'Koncept zatím nemá žádné kroky; spusťte kaloko sync.', prototypeTo:'Prototyp: přechod na', outdated:'zastaralé', hint:'Táhněte pro posun · Ctrl + kolečko zoom · <span class="kbd">F</span> režim soustředění · <span class="kbd">K</span> scénáře a běhy · <span class="kbd">R</span> projít · <span class="kbd">I</span> mapa / index · <span class="kbd">←</span><span class="kbd">→</span> předchozí / další krok (v detailu) · <span class="kbd">Esc</span> zavřít', locale:'Testovaný jazyk', view:'Náhled', both:'Oba', shownRun:'Zobrazený běh', allRuns:'Všechny běhy', steps:'Kroky flow', fit:'Celé flow', stepsWord:'kroků', passedWord:'prošlo', fitLane:'Zobrazit celou sekci', following:'Sledujete', followEsc:'Esc ukončí', followHint:'Sledovat, co dělá', visOn:'Vidí mě ostatní', visOff:'Jsem skrytý',
|
|
671
|
+
cs: { makePublicVersion:'Zveřejnit verzi', draft:'Koncept', liveShared:'živě, sdíleno', publishHint:'Publikovat verzi uloží celé flow jako %V; nezměněné kroky se znovu použijí.', htmlView:'HTML pohled', htmlViewNote:'Statická kopie stránky: bez skriptů zákazníka, s přenesenými styly a obrázky, bez tajných údajů.', editTitle:'Upravit v plátně', editText:'Text', editKeep:'ponechat', editSave:'Uložit úpravu', editSaved:'Úprava uložena jako nová revize', editRebased:'Krok se mezitím změnil; úprava je na nejnovější revizi', editUnapplied:'Neuplatněno (prvek už neexistuje)', staleCapture:'Snímek je z předchozí revize; Živě ukazuje poslední úpravu.', branches:'Větve', mergeBranch:'Sloučit do main', mergeConflict:'Krok se změnil v obou větvích:', mergeTakeBranch:'Vzít z větve? (Zrušit = ponechat main)', branchMerged:'Sloučeno', acceptVersion:'Přijmout verzi', returnVersion:'Vrátit verzi', versionVerdict:'Verdikt verze', draftInfo:'Tento koncept je živý a sdílený: všichni, kdo ho mají otevřený, vidí změny hned, i změny agentů. Kroky ukládá kaloko sync nebo úprava v plátně; tlačítko Publikovat verzi uloží celé flow jako novou verzi.', changedSince:'Změněno od', noChangeSince:'Beze změny od', designEnv:'design', version:'Verze', versions:'Verze flow', publish:'Publikovat verzi', publishName:'Název verze (nepovinný)', published:'Publikováno', designRef:'reference designu', setDesignRef:'Nastavit jako referenci designu', unsetDesignRef:'Zrušit referenci designu', designRefHint:'Implementace se kontroluje proti referenci designu (pack design).', openDraft:'Otevřít koncept', liveMode:'Živě', shotMode:'Snímek', inspect:'Inspektor', pinComment:'Komentář k prvku', pickHint:'Klikněte na prvek v náhledu.', pinnedOn:'K prvku', unchangedSince:'beze změny od', unchangedSteps:'kroků beze změny', changedOnly:'Jen změněné', stepStatus:'Stav kroku', st_in_progress:'rozpracováno', st_ready_for_review:'k revizi', st_ready_for_dev:'připraveno pro vývoj', present:'Prezentovat', presenting:'Prezentujete', stopPresent:'Ukončit prezentaci', updatedSteps:'upravil', viaAgent:'přes', here:'tady', copyCss:'Kopírovat CSS', cssCopied:'CSS zkopírováno', noToken:'mimo sadu tokenů', tokenSet:'token', revisionBy:'revize', noLiveOrigin:'Živé kroky nejsou na této instanci zapnuté.', draftEmpty:'Koncept zatím nemá žádné kroky; spusťte kaloko sync.', prototypeTo:'Prototyp: přechod na', outdated:'zastaralé', prototype:'Prototyp', protoTitle:'Prezentovat prototyp', protoHint:'Klikejte v prototypu; ← → předchozí / další krok, Esc konec.', protoShot:'Jen snímky: živé kroky tu nejsou k dispozici. ← → procházejí flow.', protoLocal:'Odkazy otevírají stránky z disku; ← → procházejí flow.', protoDevice:'Rámeček zařízení', protoFull:'Celá obrazovka', protoExit:'Ukončit prototyp', protoNoStep:'Odkaz nevede na žádný krok flow', hint:'Táhněte pro posun · Ctrl + kolečko zoom · <span class="kbd">F</span> režim soustředění · <span class="kbd">K</span> scénáře a běhy · <span class="kbd">R</span> projít · <span class="kbd">P</span> prototyp · <span class="kbd">I</span> mapa / index · <span class="kbd">←</span><span class="kbd">→</span> předchozí / další krok (v detailu) · <span class="kbd">Esc</span> zavřít', locale:'Testovaný jazyk', view:'Náhled', both:'Oba', shownRun:'Zobrazený běh', allRuns:'Všechny běhy', steps:'Kroky flow', fit:'Celé flow', stepsWord:'kroků', passedWord:'prošlo', fitLane:'Zobrazit celou sekci', following:'Sledujete', followEsc:'Esc ukončí', followHint:'Sledovat, co dělá', visOn:'Vidí mě ostatní', visOff:'Jsem skrytý',
|
|
650
672
|
pass:'Splněno', partial:'Částečně', fail:'Nesplněno', skip:'Neprověřeno', crop:'výřez', html:'HTML snapshot', email:'e-mail', external:'třetí strana',
|
|
651
673
|
branch:'Větev', commit:'Commit', author:'Autor', pr:'PR', issue:'Issue', env:'Prostředí', duration:'Délka', noPr:'bez PR', open:'otevřený', merged:'sloučený', closed:'zavřený',
|
|
652
674
|
older:'Starší běh', newer:'Novější běh', purpose:'Účel obrazovky ze scénáře', criteria:'Akceptační kritéria', jevTitle:'Sémantické hodnocení',
|
|
@@ -667,10 +689,10 @@ const I18N = {
|
|
|
667
689
|
onlyLocal:'Tento běh je jen lokálně; otevřete jeho složku.', linksExpired:'Odkazy na snímky vypršely. Obnovte stránku.', captured:'Zachyceno', console:'Konzole', errors:'chyb', blocked:'blokovaných požadavků', files:'Soubory',
|
|
668
690
|
pagesWord:'stránek', firstScreen:'první obrazovka', layout:'Rozložení', uploadedBy:'Nahrál', addNote:'Přidat poznámku', viewOptions:'Zobrazení', keyComment:'komentář ke kroku (v detailu)', keyHelp:'tato nápověda', helpTitle:'Zkratky a nápověda', flowComments:'Komentáře k flow', runComments:'Komentáře k běhu', commentsTitle:'Komentáře', allOpen:'Otevřené', openWord:'otevřených', flowCommentsLead:'Platí pro celé flow napříč běhy: chybějící krok, obecná připomínka, nápad.', noStepComments:'Ke kroku zatím nikdo nic nenapsal.', noComments:'Zatím žádné komentáře.', showResolved:'Vyřešené', hideResolved:'Skrýt vyřešené', replyAct:'Odpovědět', reopenAct:'Znovu otevřít', editAct:'Upravit', deleteAct:'Smazat', saveAct:'Uložit', cancel:'Zrušit', edited:'upraveno', commentDeleted:'Komentář byl smazán.', viaAgent:'Napsal agent', showPin:'Ukázat na snímku', pinOnShot:'Připnout na snímek', pinClickShot:'Klikněte do snímku', pinnedOnShot:'Připnuto na snímek', replyPh:'Odpověď… (@jméno upozorní)', flowPh:'Komentář k celému flow… (@jméno upozorní)', runPh:'Komentář k celému běhu… (@jméno upozorní)', commentLabel:'Komentář', ctrlEnter:'Ctrl+Enter odešle', confirmDelete:'Smazat komentář?', deletedToast:'Smazáno', openCommentsN:'Nevyřízené komentáře', showAllN:'Zobrazit všech %N%', screenWord:'obrazovka aplikace', uiTree:'UI strom', platformWord:'Platforma', treeHint:'Klikněte na prvek: ukáže ho na snímku.', treeSearch:'Hledat v UI stromu…', treeOcr:'Text rozpoznaný ze snímku (OCR): bez rolí a ovládacích prvků.', treeLoadFail:'UI strom se nepodařilo načíst.', treeEmpty:'Strom je prázdný.', mapView:'Mapa', indexView:'Index', searchSteps:'Hledat v krocích, kritériích a stránkách…', criteriaCol:'Kritéria', notesCol:'Poznámky', noStepsMatch:'Žádný krok neodpovídá filtru.', riKeys:'/ hledat · ↑↓ vybrat · Enter otevřít · I mapa', consoleWord:'chyb v konzoli', loadingPages:'hledám i ve stránkách stacků…', sortTitle:'Název', runWord:'Běh', openIndex:'Otevřít index', pagesNeedAttention:'stránek chce pozornost', allPagesPass:'všechny stránky prošly', stackLoadFail:'Index stránek se nepodařilo načíst', semanticSample:'sémantický vzorek', downloadCsv:'Stáhnout CSV', allWord:'Vše', criterionFilter:'Neprošlé kritérium', anyCriterion:'Jakékoli kritérium', searchPages:'Hledat v titulku, URL a polích…', sortBy:'Řadit podle', sortOrder:'Pořadí', sortStatus:'Stav (nejhorší první)', sortDir:'Směr řazení', pageWord:'Stránka', statusWord:'Stav', failingCriteria:'Neprošlo', stackKeys:'/ hledat · ↑↓ vybrat · Enter otevřít · Esc zavřít', listView:'Seznam', gridView:'Mřížka', noPagesMatch:'Filtru neodpovídá žádná stránka.', backToIndex:'Zpět na index',
|
|
669
691
|
subject:'Předmět', from:'Od', to:'Komu', received:'Přijato', mailBeforeRun:'přišel před začátkem běhu', noCapture:'Bez snímku', viewEval:'eval.json', decision:'rozhodnutí', mainLane:'Hlavní průchod',
|
|
670
|
-
downloadZip:'Stáhnout ZIP', filesWord:'souborů', publicAccess:'veřejný odkaz', orgAccess:'přístup po přihlášení do organizace', projectAll:'Všechny projekty', packNote:'Kritérium z balíčku kontrol (packs ve scénáři)', coverage:'Pokrytí plánu', coverageLine:'kritérií z ACCEPTANCE.md má kontrolu', coverageExtra:'kontrol mimo plán', coverageFull:'Každé plánované kritérium má kontrolu.', coverageSteps:'Kroky v plánu bez uzlu', flakyTitle:'Nestabilní kritéria', flaky:'nestabilní', flakyNote:'Verdikt se liší mezi běhy na stejném commitu a prostředí.', people:'Lidé ve flow', peopleHint:'Klik na osobu zvýrazní její kroky; přerušované šipky jsou předání mezi lidmi.', awaiting:'Čeká na schválení od', approvedBy:'schválili', approvedByAll:'Schválili všichni povinní schvalovatelé.', publicRun:'Veřejný běh', publicNote:'Plachta je čitelná bez přihlášení; jména hodnotitelů jsou skrytá.', makePublic:'Zveřejnit běh', makePrivate:'Zrušit zveřejnění', publicHint:'Jen pro scénáře bez osobních údajů', publicConfirm:'Zveřejnit tento běh? Uvidí ho kdokoli s odkazem, bez přihlášení. Použijte jen u scénářů bez osobních údajů.', openInApp:'Otevřít v Kaloku', signInLink:'Přihlásit', replay:'Projít', rpStep:'Krok', rpNext:'Další', rpPrev:'Zpět', rpEnd:'Konec průchodu.', rpRestart:'Znovu od začátku', rpChoose:'Kudy dál?', rpOr:'nebo', rpKeys:'← → krok · čísla volí větev · Esc zavře', rpDecision:'Rozhodnutí', rpBranch:'větev', rpAltOnly:'jiná větev',
|
|
692
|
+
downloadZip:'Stáhnout ZIP', downloadZipNote:'Všechny soubory v otevřených formátech a plátno, které se otevře offline z disku', filesWord:'souborů', publicAccess:'veřejný odkaz', orgAccess:'přístup po přihlášení do organizace', projectAll:'Všechny projekty', packNote:'Kritérium z balíčku kontrol (packs ve scénáři)', coverage:'Pokrytí plánu', coverageLine:'kritérií z ACCEPTANCE.md má kontrolu', coverageExtra:'kontrol mimo plán', coverageFull:'Každé plánované kritérium má kontrolu.', coverageSteps:'Kroky v plánu bez uzlu', flakyTitle:'Nestabilní kritéria', flaky:'nestabilní', flakyNote:'Verdikt se liší mezi běhy na stejném commitu a prostředí.', people:'Lidé ve flow', peopleHint:'Klik na osobu zvýrazní její kroky; přerušované šipky jsou předání mezi lidmi.', awaiting:'Čeká na schválení od', approvedBy:'schválili', approvedByAll:'Schválili všichni povinní schvalovatelé.', publicRun:'Veřejný běh', publicNote:'Plachta je čitelná bez přihlášení; jména hodnotitelů jsou skrytá.', makePublic:'Zveřejnit běh', makePrivate:'Zrušit zveřejnění', publicHint:'Jen pro scénáře bez osobních údajů', publicConfirm:'Zveřejnit tento běh? Uvidí ho kdokoli s odkazem, bez přihlášení. Použijte jen u scénářů bez osobních údajů.', openInApp:'Otevřít v Kaloku', signInLink:'Přihlásit', replay:'Projít', rpStep:'Krok', rpNext:'Další', rpPrev:'Zpět', rpEnd:'Konec průchodu.', rpRestart:'Znovu od začátku', rpChoose:'Kudy dál?', rpOr:'nebo', rpKeys:'← → krok · čísla volí větev · Esc zavře', rpDecision:'Rozhodnutí', rpBranch:'větev', rpAltOnly:'jiná větev',
|
|
671
693
|
compare:'Porovnat', cmpOff:'nic', cmpBaseline:'referenční', cmpPrev:'předchozí', cmpTitle:'Rozdíly oproti', cmpNone:'Statusy, verdikty i evidence jsou stejné.', pxChanged:'pixelů jinak', cmpSide:'Vedle sebe', cmpDiff:'Rozdíl', cmpLoadErr:'Porovnávaný běh se nepodařilo načíst.', pxUnavail:'Pixelový rozdíl se počítá jen ve službě (snímky ze stejného původu).', was:'předtím', thisRun:'tento běh', cmpNoCap:'v porovnávaném běhu bez snímku', cmpAdded:'nové kroky', cmpRemoved:'kroky, které zmizely', cmpHint:'Ctrl/⌘ + klik na krok otevře porovnání',
|
|
672
694
|
reviewTitle:'Akceptace a komentáře', approve:'Schválit', reject:'Zamítnout', comment:'Komentovat', notePh:'Co je potřeba změnit, nebo poznámka · @jméno pošle kolegovi upozornění', approved:'schváleno', rejected:'zamítnuto', commented:'komentář', resolved:'vyřešeno', resolve:'Vyřešit', openFeedback:'Otevřená zpětná vazba', noFeedback:'Žádná otevřená zpětná vazba.', saved:'Uloženo', noteRequired:'Napište, co je potřeba změnit.', expires:'Platí do' },
|
|
673
|
-
en: { makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: no scripts of the site, styles and images rehosted, secrets removed.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, agents’ changes too. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">I</span> map / index · <span class="kbd">←</span><span class="kbd">→</span> previous / next step (in the detail) · <span class="kbd">Esc</span> close', locale:'Tested locale', view:'View', both:'Both', shownRun:'Shown run', allRuns:'All runs', steps:'Flow steps', fit:'Fit flow', stepsWord:'steps', passedWord:'passed', fitLane:'Show the whole section', following:'Following', followEsc:'Esc to stop', followHint:'Follow what they do', visOn:'Others see me', visOff:'Hidden',
|
|
695
|
+
en: { makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: no scripts of the site, styles and images rehosted, secrets removed.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, agents’ changes too. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', prototype:'Prototype', protoTitle:'Present the prototype', protoHint:'Click through the prototype; ← → previous / next step, Esc to leave.', protoShot:'Screenshots only: live steps are not available here. ← → move through the flow.', protoLocal:'Links open the pages from disk; ← → move through the flow.', protoDevice:'Device frame', protoFull:'Full screen', protoExit:'Leave the prototype', protoNoStep:'The link does not lead to a step of the flow', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">P</span> prototype · <span class="kbd">I</span> map / index · <span class="kbd">←</span><span class="kbd">→</span> previous / next step (in the detail) · <span class="kbd">Esc</span> close', locale:'Tested locale', view:'View', both:'Both', shownRun:'Shown run', allRuns:'All runs', steps:'Flow steps', fit:'Fit flow', stepsWord:'steps', passedWord:'passed', fitLane:'Show the whole section', following:'Following', followEsc:'Esc to stop', followHint:'Follow what they do', visOn:'Others see me', visOff:'Hidden',
|
|
674
696
|
pass:'Passed', partial:'Partial', fail:'Failed', skip:'Not checked', crop:'crop', html:'HTML snapshot', email:'e-mail', external:'third party',
|
|
675
697
|
branch:'Branch', commit:'Commit', author:'Author', pr:'PR', issue:'Issue', env:'Environment', duration:'Duration', noPr:'no PR', open:'open', merged:'merged', closed:'closed',
|
|
676
698
|
older:'Older run', newer:'Newer run', purpose:'Screen purpose from the scenario', criteria:'Acceptance criteria', jevTitle:'Semantic evaluation',
|
|
@@ -691,7 +713,7 @@ const I18N = {
|
|
|
691
713
|
onlyLocal:'This run exists locally only; open its folder.', linksExpired:'Links to the captures expired. Reload the page.', captured:'Captured', console:'Console', errors:'errors', blocked:'blocked requests', files:'Files',
|
|
692
714
|
pagesWord:'pages', firstScreen:'first screen', layout:'Layout', uploadedBy:'Uploaded by', addNote:'Add a note', viewOptions:'View', keyComment:'comment on the step (in the detail)', keyHelp:'this help', helpTitle:'Shortcuts and help', flowComments:'Flow comments', runComments:'Run comments', commentsTitle:'Comments', allOpen:'Open', openWord:'open', flowCommentsLead:'For the whole flow across runs: a missing step, a general remark, an idea.', noStepComments:'No comments on this step yet.', noComments:'No comments yet.', showResolved:'Resolved', hideResolved:'Hide resolved', replyAct:'Reply', reopenAct:'Reopen', editAct:'Edit', deleteAct:'Delete', saveAct:'Save', cancel:'Cancel', edited:'edited', commentDeleted:'This comment was deleted.', viaAgent:'Written by an agent', showPin:'Show on the screenshot', pinOnShot:'Pin on the screenshot', pinClickShot:'Click on the screenshot', pinnedOnShot:'Pinned on the screenshot', replyPh:'Reply… (@name notifies)', flowPh:'Comment on the whole flow… (@name notifies)', runPh:'Comment on the whole run… (@name notifies)', commentLabel:'Comment', ctrlEnter:'Ctrl+Enter sends', confirmDelete:'Delete this comment?', deletedToast:'Deleted', openCommentsN:'Open comments', showAllN:'Show all %N%', screenWord:'app screen', uiTree:'UI tree', platformWord:'Platform', treeHint:'Click an element to show it on the screen.', treeSearch:'Search the UI tree…', treeOcr:'Text recognized from the screenshot (OCR): no roles or controls.', treeLoadFail:'The UI tree could not be loaded.', treeEmpty:'The tree is empty.', mapView:'Map', indexView:'Index', searchSteps:'Search steps, criteria and pages…', criteriaCol:'Criteria', notesCol:'Notes', noStepsMatch:'No step matches the filter.', riKeys:'/ search · ↑↓ select · Enter open · I map', consoleWord:'console errors', loadingPages:'also searching the pages of stacks…', sortTitle:'Title', runWord:'Run', openIndex:'Open index', pagesNeedAttention:'pages need attention', allPagesPass:'all pages pass', stackLoadFail:'The page index could not be loaded', semanticSample:'semantic sample', downloadCsv:'Download CSV', allWord:'All', criterionFilter:'Failing criterion', anyCriterion:'Any criterion', searchPages:'Search title, URL and fields…', sortBy:'Sort by', sortOrder:'Order', sortStatus:'Status (worst first)', sortDir:'Sort direction', pageWord:'Page', statusWord:'Status', failingCriteria:'Failing', stackKeys:'/ search · ↑↓ select · Enter open · Esc close', listView:'List', gridView:'Grid', noPagesMatch:'No page matches the filter.', backToIndex:'Back to index',
|
|
693
715
|
subject:'Subject', from:'From', to:'To', received:'Received', mailBeforeRun:'arrived before the run started', noCapture:'No capture', viewEval:'eval.json', decision:'decision', mainLane:'Main path',
|
|
694
|
-
downloadZip:'Download ZIP', filesWord:'files', publicAccess:'public link', orgAccess:'access after signing in to the organization', projectAll:'All projects', packNote:'Criterion from a check pack (packs in the scenario)', coverage:'Plan coverage', coverageLine:'criteria from ACCEPTANCE.md have a check', coverageExtra:'checks outside the plan', coverageFull:'Every planned criterion has a check.', coverageSteps:'Steps in the plan without a node', flakyTitle:'Flaky criteria', flaky:'flaky', flakyNote:'The verdict differs between runs on the same commit and environment.', people:'People in the flow', peopleHint:'Click a person to highlight their steps; dashed arrows are hand-offs between people.', awaiting:'Awaiting approval from', approvedBy:'approved by', approvedByAll:'Every required approver has approved.', publicRun:'Public run', publicNote:'The canvas is readable without sign-in; reviewer names are hidden.', makePublic:'Make public', makePrivate:'Make private', publicHint:'Only for scenarios without personal data', publicConfirm:'Make this run public? Anyone with the link can read it without signing in. Use it only for scenarios without personal data.', openInApp:'Open in Kaloko', signInLink:'Sign in', replay:'Replay', rpStep:'Step', rpNext:'Next', rpPrev:'Back', rpEnd:'End of the walkthrough.', rpRestart:'Start over', rpChoose:'Which way?', rpOr:'or', rpKeys:'← → step · digits pick a branch · Esc closes', rpDecision:'Decision', rpBranch:'branch', rpAltOnly:'other branch',
|
|
716
|
+
downloadZip:'Download ZIP', downloadZipNote:'Every file in open formats and the canvas, which opens offline from disk', filesWord:'files', publicAccess:'public link', orgAccess:'access after signing in to the organization', projectAll:'All projects', packNote:'Criterion from a check pack (packs in the scenario)', coverage:'Plan coverage', coverageLine:'criteria from ACCEPTANCE.md have a check', coverageExtra:'checks outside the plan', coverageFull:'Every planned criterion has a check.', coverageSteps:'Steps in the plan without a node', flakyTitle:'Flaky criteria', flaky:'flaky', flakyNote:'The verdict differs between runs on the same commit and environment.', people:'People in the flow', peopleHint:'Click a person to highlight their steps; dashed arrows are hand-offs between people.', awaiting:'Awaiting approval from', approvedBy:'approved by', approvedByAll:'Every required approver has approved.', publicRun:'Public run', publicNote:'The canvas is readable without sign-in; reviewer names are hidden.', makePublic:'Make public', makePrivate:'Make private', publicHint:'Only for scenarios without personal data', publicConfirm:'Make this run public? Anyone with the link can read it without signing in. Use it only for scenarios without personal data.', openInApp:'Open in Kaloko', signInLink:'Sign in', replay:'Replay', rpStep:'Step', rpNext:'Next', rpPrev:'Back', rpEnd:'End of the walkthrough.', rpRestart:'Start over', rpChoose:'Which way?', rpOr:'or', rpKeys:'← → step · digits pick a branch · Esc closes', rpDecision:'Decision', rpBranch:'branch', rpAltOnly:'other branch',
|
|
695
717
|
compare:'Compare', cmpOff:'nothing', cmpBaseline:'baseline', cmpPrev:'previous', cmpTitle:'Differences from', cmpNone:'Statuses, verdicts and evidence are the same.', pxChanged:'pixels changed', cmpSide:'Side by side', cmpDiff:'Diff', cmpLoadErr:'Could not load the compared run.', pxUnavail:'Pixel diff works in the service only (same-origin images).', was:'was', thisRun:'this run', cmpNoCap:'no capture in the compared run', cmpAdded:'new steps', cmpRemoved:'steps that disappeared', cmpHint:'Ctrl/⌘ + click a step to open the comparison',
|
|
696
718
|
reviewTitle:'Sign-off and comments', approve:'Approve', reject:'Reject', comment:'Comment', notePh:'What should change, or a note · @name notifies a colleague', approved:'approved', rejected:'rejected', commented:'comment', resolved:'resolved', resolve:'Resolve', openFeedback:'Open feedback', noFeedback:'No open feedback.', saved:'Saved', noteRequired:'Write what should change.', expires:'Expires' }
|
|
697
719
|
};
|
|
@@ -2083,7 +2105,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2083
2105
|
const lb = $('lb');
|
|
2084
2106
|
const html = `<div class="lb__box">
|
|
2085
2107
|
<div class="lb__stage">
|
|
2086
|
-
<div class="lb__tools"><div class="seg" id="lbVp"></div>${liveSrc(cap) || viewSrc(cap) ? `<div class="seg" id="lbMode"></div>` : ''}${(liveSrc(cap) && S.lbMode === 'live') || (viewSrc(cap) && S.lbMode === 'view') ? `<span id="lbLiveTools" class="share__row"></span>` : ''}${S.cmp ? `<div class="seg" id="lbCmpSeg"></div>` : ''}<span class="note">${v.w} × ${v.h}, ${cap?.platform && cap.platform !== 'electron' ? T('screenWord') : cap?.first_screen ? T('firstScreen') : T('fullPage')} · ${esc(S.loc)}</span><span class="spacer"></span><span class="note">${S.cmp ? `${T('cmpTitle')} ${esc(cmpLabel())}` : T('hover')}</span></div>
|
|
2108
|
+
<div class="lb__tools"><div class="seg" id="lbVp"></div>${isDesign() || liveSrc(cap) ? `<button class="btn" id="lbProto" title="${esc(T('protoTitle'))} (P)">${T('prototype')}</button>` : ''}${liveSrc(cap) || viewSrc(cap) ? `<div class="seg" id="lbMode"></div>` : ''}${(liveSrc(cap) && S.lbMode === 'live') || (viewSrc(cap) && S.lbMode === 'view') ? `<span id="lbLiveTools" class="share__row"></span>` : ''}${S.cmp ? `<div class="seg" id="lbCmpSeg"></div>` : ''}<span class="note">${v.w} × ${v.h}, ${cap?.platform && cap.platform !== 'electron' ? T('screenWord') : cap?.first_screen ? T('firstScreen') : T('fullPage')} · ${esc(S.loc)}</span><span class="spacer"></span><span class="note">${S.cmp ? `${T('cmpTitle')} ${esc(cmpLabel())}` : T('hover')}</span></div>
|
|
2087
2109
|
<div class="lb__scroll" id="lbScroll">${(() => {
|
|
2088
2110
|
const bn0 = S.cmp && cmpNode(n.id), bcap0 = bn0?.captures?.[S.loc]?.[v.name];
|
|
2089
2111
|
if (S.lbMode === 'view' && viewSrc(cap) && !S.cmp) return `<p class="note" style="margin:0 0 8px">${T('htmlViewNote')}</p>` + liveStage(cap, v, 'lbFrame', RUN.live_bases, true);
|
|
@@ -2146,6 +2168,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2146
2168
|
seg($('lbVp'), (n.item ? viewports().filter((x) => n.captures?.[S.loc]?.[x.name]) : viewports()).map((x) => [x.name, vpLabel(x)]), v.name, (nv) => openLb(id, nv));
|
|
2147
2169
|
if ($('lbMode')) seg($('lbMode'), [...(liveSrc(cap) ? [['live', T('liveMode')]] : []), ...(viewSrc(cap) ? [['view', T('htmlView')]] : []), ['shot', T('shotMode')]], S.lbMode, (m) => { S.lbMode = m; LIVE.mode = null; openLb(id, v.name); });
|
|
2148
2170
|
if ($('lbLiveTools')) renderLiveTools();
|
|
2171
|
+
if ($('lbProto')) $('lbProto').onclick = () => ptOpen(id, v.name);
|
|
2149
2172
|
wireStepStatus(n); wireCopyCss();
|
|
2150
2173
|
// pins of comments made on elements, drawn over the screenshot too
|
|
2151
2174
|
const pimg = $('lbImg');
|
|
@@ -2233,6 +2256,10 @@ function applyHash () {
|
|
|
2233
2256
|
if (p.get('replay')) { const path = (p.get('path') || p.get('step') || '').split(',').filter((id) => byId(id)); if (!S.rp || S.rp.path.join(',') !== path.join(',')) rpOpen(path); return; }
|
|
2234
2257
|
if (S.rp) rpClose();
|
|
2235
2258
|
if (p.get('comments')) showCommentsFromHash(p.get('comments'));
|
|
2259
|
+
// a shared link straight into the prototype: #present=<step>&loc=…&vp=…
|
|
2260
|
+
const pres = p.get('present');
|
|
2261
|
+
if (pres && byId(pres)) { if (p.get('loc') && RUN.scope.locales.includes(p.get('loc')) && p.get('loc') !== S.loc) { S.loc = p.get('loc'); renderAll(); } ptOpen(pres, p.get('vp')); return; }
|
|
2262
|
+
if (!$('pt').hidden) { $('pt').hidden = true; $('pt').innerHTML = ''; }
|
|
2236
2263
|
const step = p.get('step');
|
|
2237
2264
|
if (step && step.includes('~') && !byId(step) && byId(step.split('~')[0])?.kind === 'stack') { loadStack(byId(step.split('~')[0])).then((d) => { if (d) { S.itemList = d.items.map((it) => ensureItemNode(byId(step.split('~')[0]), d, it).id); applyHash(); } }); return; }
|
|
2238
2265
|
if (!step || !byId(step)) return;
|
|
@@ -2353,7 +2380,7 @@ function renderReport () {
|
|
|
2353
2380
|
${isDesign() ? '' : `<div class="sec"><h4>${T('changes')}</h4>`}${isDesign() ? '' : pr ? (changes.length ? `<ul class="issues">${changes.map((n) => `<li><button data-go="${esc(n.id)}"><span class="badge st-${esc(n.status)}">Δ</span><span><b>${esc(loc(n.title))}</b></span><small>${esc(T(pr.statuses[n.id]))} (${esc(pr.git?.commit || '')}) → ${esc(T(n.status))} (${esc(RUN.git?.commit || '')})</small></button></li>`).join('')}</ul>` : `<p class="note">${T('noChanges')}</p>`) : `<p class="note">${T('noPrev')}</p>`}${isDesign() ? '' : '</div>'}
|
|
2354
2381
|
<div class="sec share"><h4>${ICON.share} ${T('share')}</h4>${APP()
|
|
2355
2382
|
? `<div class="url" id="shareUrl">${esc(location.origin + location.pathname)}</div>
|
|
2356
|
-
<div class="share__row"><button class="btn btn--primary" id="shareCopy">${ICON.copy} ${T('copyLink')}</button>${APP().user && APP().run ? `<a class="btn" href="${esc(`${APP().api}/orgs/${APP().org}/runs/${APP().run}/export.zip`)}" download>${ICON.download} ${T(
|
|
2383
|
+
<div class="share__row"><button class="btn btn--primary" id="shareCopy">${ICON.copy} ${T('copyLink')}</button>${APP().user && APP().run ? `<a class="btn" href="${esc(`${APP().api}/orgs/${APP().org}/runs/${APP().run}/export.zip`)}" download title="${esc(T("downloadZipNote"))}">${ICON.download} ${T("downloadZip")}</a>` : ''}</div>
|
|
2357
2384
|
<p class="note">${[RUN.expires_at && `${T('expires')} ${fmtDate(RUN.expires_at)}`, RUN.usage && `${T('storage')}: ${fmtBytes(RUN.usage.bytes)} · ${RUN.usage.files} ${T('filesWord')}`, RUN.public ? T('publicAccess') : T('orgAccess')].filter(Boolean).map(esc).join(' · ')}</p>`
|
|
2358
2385
|
: `<div class="url">kaloko share</div><p class="note">${T('shareLocal')}</p>`}</div>`;
|
|
2359
2386
|
$('rsClose').onclick = () => { S.right = false; setPanels(); };
|
|
@@ -2440,7 +2467,8 @@ const scenarioApi = (p) => { const a = APP(); return `${a.api}/orgs/${a.org}/sce
|
|
|
2440
2467
|
const branchQ = () => RUN.draft && RUN.draft.branch !== 'main' ? `?branch=${enc(RUN.draft.branch)}` : '';
|
|
2441
2468
|
function liveSrc (cap, bases = RUN.live_bases) {
|
|
2442
2469
|
const l = cap?.live; if (!l) return null;
|
|
2443
|
-
|
|
2470
|
+
// from disk: the merged tree of the run (links between steps resolve there), else the revision's own copy
|
|
2471
|
+
if (!APP()) return RUN.live_site === 'live/site/' ? RUN.live_site + l.entry : `live/${l.revision}/${l.entry}`;
|
|
2444
2472
|
const b = bases?.[l.revision];
|
|
2445
2473
|
return b ? b + l.entry : null;
|
|
2446
2474
|
}
|
|
@@ -2471,7 +2499,7 @@ async function reloadDraft () {
|
|
|
2471
2499
|
const m = await apiJson('GET', scenarioApi(`/draft/manifest${branchQ()}`)).catch(() => null);
|
|
2472
2500
|
if (!m) return;
|
|
2473
2501
|
swapBase(BASE, m.files_base); BASE = m.files_base || BASE; a.files = m.files_base;
|
|
2474
|
-
RUN = m; applyReviewerVerdicts(); renderAll(); if (S.open) openLb(S.open, S.lbVp, { keepFrame: true });
|
|
2502
|
+
RUN = m; applyReviewerVerdicts(); renderAll(); if (S.open && $('pt').hidden) openLb(S.open, S.lbVp, { keepFrame: true });
|
|
2475
2503
|
}
|
|
2476
2504
|
|
|
2477
2505
|
function renderDesignBar () {
|
|
@@ -2534,7 +2562,7 @@ function liveStage (cap, v, id = 'lbFrame', bases, view = false) {
|
|
|
2534
2562
|
// an HTML view gets no forms and runs only the frame helper (its origin's CSP enforces that too)
|
|
2535
2563
|
return `<div class="lb__live"><iframe id="${id}" title="${view ? T('htmlView') : T('liveMode')}" sandbox="${view ? 'allow-scripts' : 'allow-scripts allow-forms'}" referrerpolicy="no-referrer" src="${esc(src)}" style="width:${v.w}px;height:${Math.min(v.h, 2000)}px"></iframe></div>`;
|
|
2536
2564
|
}
|
|
2537
|
-
const frames = () => [...document.querySelectorAll('#lbFrame, #lbFrameB')];
|
|
2565
|
+
const frames = () => [...document.querySelectorAll('#lbFrame, #lbFrameB, #ptIframe')];
|
|
2538
2566
|
// a live step renders at its real viewport width and is scaled to fit the stage, like the screenshot
|
|
2539
2567
|
function fitLive () {
|
|
2540
2568
|
for (const f of frames()) {
|
|
@@ -2565,6 +2593,13 @@ addEventListener('message', (e) => {
|
|
|
2565
2593
|
if (!frames().some((f) => e.source === f.contentWindow)) return;
|
|
2566
2594
|
const m = e.data || {};
|
|
2567
2595
|
const n = S.open && byId(S.open);
|
|
2596
|
+
// presenting: the frame moves between steps (links, data-kaloko-go) and hands over the keys it does not use
|
|
2597
|
+
if (!$('pt').hidden) {
|
|
2598
|
+
const str = (x) => typeof x === 'string' ? x.slice(0, 300).replace(/[#\s]/g, '') : '';
|
|
2599
|
+
if (m.type === 'qawalk:go' && typeof m.node === 'string') { if (byId(m.node) && byId(m.node).kind !== 'decision') ptOpen(m.node, PT.vp, { query: str(m.query), hash: str(m.hash) }); else toast(T('protoNoStep')); }
|
|
2600
|
+
if (m.type === 'qawalk:key') ptKey({ key: String(m.key), preventDefault () {} });
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2568
2603
|
if (m.type === 'qawalk:ready' && n) { toFrame({ type: 'qawalk:pins', pins: pinsFor(n, S.lbVp) }); if (LIVE.mode) toFrame({ type: 'qawalk:mode', mode: LIVE.mode }); }
|
|
2569
2604
|
if (m.type === 'qawalk:go' && typeof m.node === 'string' && byId(m.node)) { toast(`${T('prototypeTo')} ${loc(byId(m.node).title)}`); openLb(m.node, S.lbVp); }
|
|
2570
2605
|
if (m.type === 'qawalk:hover' && LIVE.mode === 'inspect' && m.el) { LIVE.insp = sanitizeEl(m.el); const sec = $('inspSec'); if (sec) sec.outerHTML = inspectorHtml(); else $('lbSideTop')?.insertAdjacentHTML('afterend', inspectorHtml()); wireCopyCss(); }
|
|
@@ -2626,6 +2661,69 @@ function wireStepStatus (n) {
|
|
|
2626
2661
|
}
|
|
2627
2662
|
|
|
2628
2663
|
|
|
2664
|
+
// ---- prototype presentation (#42): full screen, live pages where the run has them, screenshots otherwise ----
|
|
2665
|
+
const PT = { id: null, vp: null, device: true, query: '', hash: '', from: null };
|
|
2666
|
+
const ptList = () => flowOrder().filter((n) => n.kind !== 'decision' && n.kind !== 'stack' && !n.item);
|
|
2667
|
+
function ptOpen (id, vpName, extra = {}) {
|
|
2668
|
+
const list = ptList(); if (!list.length) return;
|
|
2669
|
+
const n = list.includes(byId(id)) ? byId(id) : list[0];
|
|
2670
|
+
if ($('pt').hidden) { PT.from = $('lb').hidden ? null : S.open; PT.device = innerWidth > 700; }
|
|
2671
|
+
$('lb').hidden = true; LIVE.mode = null;
|
|
2672
|
+
PT.id = n.id; PT.query = extra.query || ''; PT.hash = extra.hash || '';
|
|
2673
|
+
PT.vp = viewports().some((v) => v.name === vpName) ? vpName : PT.vp || S.lbVp || (S.vp === 'mobile' ? mVp() : dVp())?.name || viewports()[0].name;
|
|
2674
|
+
renderPt();
|
|
2675
|
+
setHash({ present: n.id, loc: S.loc, vp: PT.vp });
|
|
2676
|
+
}
|
|
2677
|
+
function ptClose () {
|
|
2678
|
+
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
|
|
2679
|
+
$('pt').hidden = true; $('pt').innerHTML = '';
|
|
2680
|
+
if (PT.from && byId(PT.id)) openLb(PT.id, PT.vp); else setHash(null);
|
|
2681
|
+
}
|
|
2682
|
+
function ptKey (e) {
|
|
2683
|
+
const list = ptList(), i = list.indexOf(byId(PT.id));
|
|
2684
|
+
if (e.key === 'Escape') { e.preventDefault(); ptClose(); }
|
|
2685
|
+
else if ((e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') && i < list.length - 1) { e.preventDefault(); ptOpen(list[i + 1].id, PT.vp); }
|
|
2686
|
+
else if ((e.key === 'ArrowLeft' || e.key === 'PageUp') && i > 0) { e.preventDefault(); ptOpen(list[i - 1].id, PT.vp); }
|
|
2687
|
+
else if (e.key === 'Home') { e.preventDefault(); ptOpen(list[0].id, PT.vp); }
|
|
2688
|
+
else if (e.key === 'End') { e.preventDefault(); ptOpen(list[list.length - 1].id, PT.vp); }
|
|
2689
|
+
else if (e.key === 'f' || e.key === 'F') { e.preventDefault(); $('ptFull')?.click(); }
|
|
2690
|
+
else if (e.key === 'd' || e.key === 'D') { e.preventDefault(); $('ptDev')?.click(); }
|
|
2691
|
+
}
|
|
2692
|
+
function renderPt () {
|
|
2693
|
+
const el = $('pt'), list = ptList(), n = byId(PT.id), i = list.indexOf(n);
|
|
2694
|
+
const v = viewports().find((x) => x.name === PT.vp) || viewports()[0];
|
|
2695
|
+
const cap = n.captures?.[S.loc]?.[v.name];
|
|
2696
|
+
const src = liveSrc(cap);
|
|
2697
|
+
const url = src ? src + (PT.query ? `?${PT.query}` : '') + (PT.hash ? `#${PT.hash}` : '') : null;
|
|
2698
|
+
const kind = v.mobile ? 'phone' : 'desk';
|
|
2699
|
+
const screen = url ? `<iframe id="ptIframe" title="${esc(loc(n.title))}" sandbox="allow-scripts allow-forms" referrerpolicy="no-referrer" src="${esc(url)}" style="width:${v.w}px;height:${v.h}px"></iframe>`
|
|
2700
|
+
: cap ? `<img src="${esc(asset(cap.image || cap.png))}" alt="${esc(loc(n.title))}">` : `<p class="pt__empty">${T('notReached')}</p>`;
|
|
2701
|
+
const note = !src ? T('protoShot') : APP() ? T('protoHint') : T('protoLocal');
|
|
2702
|
+
el.innerHTML = `<div class="pt__bar"><span class="pt__title" id="ptTitle">${esc(loc(n.title))}</span><span class="note">${i + 1} / ${list.length}</span><span class="spacer"></span><div class="seg" id="ptVp"></div>${RUN.scope.locales.length > 1 ? '<div class="seg" id="ptLoc"></div>' : ''}<button class="btn" id="ptDev" aria-pressed="${PT.device}">${T('protoDevice')}</button>${document.fullscreenEnabled ? `<button class="btn" id="ptFull" aria-pressed="${!!document.fullscreenElement}">${T('protoFull')}</button>` : ''}<button class="btn" id="ptLink">${ICON.external} ${T('copyLink')}</button><button class="icon-btn" id="ptClose" aria-label="${esc(T('protoExit'))}" title="${esc(T('protoExit'))} (Esc)">${ICON.close}</button></div>
|
|
2703
|
+
<div class="pt__stage" id="ptStage"><div class="pt__fit" id="ptFit"><div class="pt__dev pt__dev--${kind} ${PT.device ? '' : 'pt__dev--bare'}" id="ptDevBox"><div class="pt__screen" style="width:${v.w}px;height:${v.h}px">${screen}</div></div></div></div>
|
|
2704
|
+
<div class="pt__bar pt__bar--foot"><button class="icon-btn" id="ptPrev" aria-label="Previous" ${i <= 0 ? 'disabled' : ''}>${ICON.prev}</button><span class="note">${esc(note)}</span><button class="icon-btn" id="ptNext" aria-label="Next" ${i >= list.length - 1 ? 'disabled' : ''}>${ICON.next}</button></div>`;
|
|
2705
|
+
el.hidden = false;
|
|
2706
|
+
seg($('ptVp'), viewports().map((x) => [x.name, vpLabel(x)]), v.name, (nv) => ptOpen(PT.id, nv, PT));
|
|
2707
|
+
if ($('ptLoc')) seg($('ptLoc'), RUN.scope.locales.map((l) => [l, l]), S.loc, (l) => { S.loc = l; renderAll(); ptOpen(PT.id, PT.vp, PT); });
|
|
2708
|
+
$('ptDev').onclick = () => { PT.device = !PT.device; renderPt(); };
|
|
2709
|
+
if ($('ptFull')) $('ptFull').onclick = () => { (document.fullscreenElement ? document.exitFullscreen() : el.requestFullscreen()).catch(() => {}).finally(() => setTimeout(fitPt, 120)); };
|
|
2710
|
+
$('ptLink').onclick = async () => { try { await navigator.clipboard.writeText(location.href); toast(T('linkCopied')); } catch { toast(location.href); } };
|
|
2711
|
+
$('ptClose').onclick = ptClose;
|
|
2712
|
+
$('ptPrev').onclick = () => ptKey({ key: 'ArrowLeft', preventDefault () {} });
|
|
2713
|
+
$('ptNext').onclick = () => ptKey({ key: 'ArrowRight', preventDefault () {} });
|
|
2714
|
+
fitPt(); requestAnimationFrame(fitPt);
|
|
2715
|
+
$('ptClose').focus();
|
|
2716
|
+
}
|
|
2717
|
+
// the device keeps the viewport's real size and is scaled to the stage, like a live step in the detail
|
|
2718
|
+
function fitPt () {
|
|
2719
|
+
const st = $('ptStage'), box = $('ptDevBox'), fitEl = $('ptFit'); if (!st || !box) return;
|
|
2720
|
+
const w = box.offsetWidth, h = box.offsetHeight, pad = innerWidth > 700 ? 32 : 12;
|
|
2721
|
+
const k = Math.min(1, (st.clientWidth - pad) / w, (st.clientHeight - pad) / h);
|
|
2722
|
+
box.style.transform = `scale(${k})`; fitEl.style.width = `${Math.floor(w * k)}px`; fitEl.style.height = `${Math.floor(h * k)}px`;
|
|
2723
|
+
}
|
|
2724
|
+
addEventListener('resize', () => { if (!$('pt').hidden) fitPt(); });
|
|
2725
|
+
document.addEventListener('fullscreenchange', () => { const b = $('ptFull'); if (b) b.setAttribute('aria-pressed', String(!!document.fullscreenElement)); setTimeout(fitPt, 60); });
|
|
2726
|
+
|
|
2629
2727
|
/* ---------- boot ---------- */
|
|
2630
2728
|
let tt; function toast (msg) { const el = $('toast'); el.textContent = msg; el.hidden = false; clearTimeout(tt); tt = setTimeout(() => { el.hidden = true; }, 2600); }
|
|
2631
2729
|
async function load () {
|
|
@@ -2713,6 +2811,7 @@ load().then((data) => {
|
|
|
2713
2811
|
$('togLeft').onclick = () => { S.left = !S.left; setPanels(); savePanels(); };
|
|
2714
2812
|
$('togRight').onclick = () => { S.right = !S.right; setPanels(); savePanels(); };
|
|
2715
2813
|
$('rpBtn').onclick = () => rpOpen(null);
|
|
2814
|
+
$('ptBtn').hidden = !isDesign(); $('ptBtn').onclick = () => ptOpen(null);
|
|
2716
2815
|
wireComments($('lb')); wireComments($('rside')); wireComments($('rp'));
|
|
2717
2816
|
// small menus of the top bar: one open at a time, closed by a click elsewhere or Escape
|
|
2718
2817
|
// wide screens show navigation, language and view options in the bar; narrower ones fold them into ☰
|
|
@@ -2736,6 +2835,7 @@ load().then((data) => {
|
|
|
2736
2835
|
// (the draft stays); the next Escape closes the overlay. Search boxes keep their own arrow and Enter keys.
|
|
2737
2836
|
const field = e.target.closest?.('textarea,select,[contenteditable="true"],input:not([type=checkbox]):not([type=radio]):not([type=button])');
|
|
2738
2837
|
if (field && !['siQ', 'riQ', 'brSearch'].includes(field.id)) { if (e.key === 'Escape') { e.preventDefault(); field.blur(); } return; }
|
|
2838
|
+
if (!$('pt').hidden) { if (!e.metaKey && !e.ctrlKey && !e.altKey) ptKey(e); return; }
|
|
2739
2839
|
if (!$('si').hidden) { if (stackKey(e)) return; if (e.target.closest('input,select,textarea')) return; return; }
|
|
2740
2840
|
if (S.rp && !$('rp').hidden) { if (!e.target.closest('input,select,textarea')) { e.preventDefault(); rpKey(e); } return; }
|
|
2741
2841
|
if (!$('br').hidden) { if (e.key === 'Escape') closeBrowser(); return; }
|
|
@@ -2745,6 +2845,7 @@ load().then((data) => {
|
|
|
2745
2845
|
if (e.key === 'ArrowRight') $('lbNext')?.click();
|
|
2746
2846
|
if (e.key === 'ArrowLeft') $('lbPrev')?.click();
|
|
2747
2847
|
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); focusComposer(); }
|
|
2848
|
+
if (e.key === 'p' || e.key === 'P') { e.preventDefault(); ptOpen(S.open, S.lbVp); }
|
|
2748
2849
|
return;
|
|
2749
2850
|
}
|
|
2750
2851
|
if (S.mode === 'index' && !e.metaKey && !e.ctrlKey && !e.altKey) { if (S.st?.inline ? stackKey(e) : riKey(e)) return; }
|
|
@@ -2754,6 +2855,7 @@ load().then((data) => {
|
|
|
2754
2855
|
if (e.key === 'f' || e.key === 'F') { const any = S.left || S.right; S.left = S.right = !any; setPanels(); }
|
|
2755
2856
|
if (e.key === 'k' || e.key === 'K' || (e.key === '/' && S.mode !== 'index')) { e.preventDefault(); openBrowser(); }
|
|
2756
2857
|
if (e.key === 'r' || e.key === 'R') { e.preventDefault(); rpOpen(null); }
|
|
2858
|
+
if (e.key === 'p' || e.key === 'P') { e.preventDefault(); ptOpen(null); }
|
|
2757
2859
|
if (e.key === '0') fit();
|
|
2758
2860
|
if (e.key === '1') startView();
|
|
2759
2861
|
});
|