@yargote/artifacts-cli 0.1.0-beta.6 → 0.1.0-beta.7

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 CHANGED
@@ -11,9 +11,9 @@ Use Node 24 or later. The beta targets macOS and Linux. Windows has not been ver
11
11
  Choose one persistent installation:
12
12
 
13
13
  ```sh
14
- npm install --global @yargote/artifacts-cli@0.1.0-beta.6
14
+ npm install --global @yargote/artifacts-cli@0.1.0-beta.7
15
15
  # Or, with pnpm and an existing global bin directory on PATH:
16
- pnpm add --global @yargote/artifacts-cli@0.1.0-beta.6
16
+ pnpm add --global @yargote/artifacts-cli@0.1.0-beta.7
17
17
  artifacts --version
18
18
  ```
19
19
 
@@ -22,18 +22,18 @@ If pnpm reports that no global bin directory exists, run `pnpm setup` and reopen
22
22
  For one-off execution, pin the same version:
23
23
 
24
24
  ```sh
25
- npx --yes @yargote/artifacts-cli@0.1.0-beta.6 --help
26
- pnpm dlx @yargote/artifacts-cli@0.1.0-beta.6 --help
25
+ npx --yes @yargote/artifacts-cli@0.1.0-beta.7 --help
26
+ pnpm dlx @yargote/artifacts-cli@0.1.0-beta.7 --help
27
27
  ```
28
28
 
29
29
  `@beta` follows future prereleases. Exact versions remain reproducible. No stable `latest` release is part of this beta. An organization's pnpm minimum release age or trust policy can delay a new release. Follow that policy rather than disabling it globally.
30
30
 
31
31
  ## Complete first use
32
32
 
33
- An existing Artifacts account is required. Select the complete OAuth resource, including `/mcp`:
33
+ An existing Artifacts account is required. Select the REST OAuth resource, which is the API origin without `/mcp`. When upgrading an older deployment, complete the [API migration](../../docs/deployment.md#migrate-the-api-hostname) before using the address below.
34
34
 
35
35
  ```sh
36
- export ARTIFACTS_SERVER='https://artifacts-mcp.yersonargotev.workers.dev/mcp'
36
+ export ARTIFACTS_SERVER='https://artifacts-api.yersonargotev.workers.dev'
37
37
  artifacts login
38
38
  artifacts whoami
39
39
  ```
@@ -82,7 +82,9 @@ Run `artifacts logout` before uninstalling if you want to clear credentials. Uni
82
82
 
83
83
  Skills, help, and version commands work without a server or login. Skills commands do not read credentials or create private state. They accept and ignore global `--server` and `--state-dir` values, including their environment defaults.
84
84
 
85
- `--server RESOURCE` selects the exact canonical OAuth resource. `ARTIFACTS_SERVER` supplies the same value. A resource ending in `/mcp` uses REST under `/mcp/v1`. The CLI has no production default.
85
+ `--server RESOURCE` selects the exact canonical REST OAuth resource. `ARTIFACTS_SERVER` supplies the same value. Use the configured `API_URL` origin to call REST under `/v1`. The CLI has no production default and does not rewrite the supplied URL. Older deployments without `API_URL` still use their complete MCP resource and routes under `/mcp/v1`.
86
+
87
+ A hostname change requires a new `artifacts login --server RESOURCE`. Credentials and retained operations remain bound to their exact original resource. Do not move an old operation file to the new resource or redirect its bearer requests.
86
88
 
87
89
  `--state-dir DIR` overrides `ARTIFACTS_STATE_DIR`. The default is `~/.local/state/artifacts`. The state directory must be private and owned by the current user. Credentials are separate from operation files. Profiles bind credentials to the exact resource. Retained operation paths hash the resource and request ID, so request IDs cannot select filesystem paths.
88
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yargote/artifacts-cli",
3
- "version": "0.1.0-beta.6",
3
+ "version": "0.1.0-beta.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Command-line client for the Artifacts REST API.",
@@ -27,8 +27,8 @@
27
27
  "skills"
28
28
  ],
29
29
  "dependencies": {
30
- "@yargote/artifacts-contracts": "0.1.0-beta.6",
31
- "@yargote/artifacts-client": "0.1.0-beta.6",
30
+ "@yargote/artifacts-contracts": "0.1.0-beta.7",
31
+ "@yargote/artifacts-client": "0.1.0-beta.7",
32
32
  "openid-client": "6.8.8",
33
33
  "zod": "4.6.5"
34
34
  },
@@ -17,11 +17,11 @@ Run `artifacts whoami` before the first mutation when the current identity is no
17
17
 
18
18
  A revision is a complete snapshot with `metadata`, `content`, and `files`. The command reference contains its JSON shape. Use the requested title, language, content, and attachments. Do not include unrelated files or credentials.
19
19
 
20
- Use self-contained HTML for interactive content. Inline styles and scripts and embed required assets as data URLs. External requests, CDN dependencies, relative executable assets, forms, browser storage, and multi-file websites are unsupported. Attachments are downloads, not importable website resources. Markdown and text display inertly.
20
+ Use self-contained HTML for interactive content. For several HTML boards in one canvas, use `design` and follow the design skill's `references/canvas.md`. Inline styles and scripts and embed required assets as data URLs. External requests, CDN dependencies, relative executable assets, forms, browser storage, and multi-file websites are unsupported. Attachments are downloads, not importable website resources. Markdown and text display inertly.
21
21
 
22
22
  Keep the whole revision within 1 MiB of decoded content, metadata, attachment paths, media types, and attachment bytes. At most 32 attachments are accepted. Base64 changes wire size, not the decoded limit. If required content exceeds a limit, explain the constraint without silently removing it.
23
23
 
24
- Before writing or restyling HTML, run `artifacts skills get artifacts-design --full` and follow it. It owns typography, embedded fonts, color, layout, and the rendered checks. Finish those checks before saving when browser tools are available. A successful CLI write proves persistence, not visual quality or browser compatibility. Interactive HTML follows the server's Chrome policy; do not promise it in every browser.
24
+ Before writing or restyling HTML or design boards, run `artifacts skills get artifacts-design --full` and follow it. It owns typography, embedded fonts, color, layout, and the rendered checks. Finish those checks before saving when browser tools are available. A successful CLI write proves persistence, not visual quality or browser compatibility. Interactive HTML follows the server's Chrome policy; do not promise it in every browser.
25
25
 
26
26
  ## Create or revise
27
27
 
@@ -63,10 +63,12 @@ A `request_conflict` means the ID was used with different arguments. Do not desc
63
63
 
64
64
  ## Verify and deliver
65
65
 
66
- After create or update, read the explicit returned revision. Use `read ARTIFACT_ID --revision REVISION_ID --out FILE --quiet` to retain the full response without printing its snapshot. Check that the installed `--help` supports `--quiet` first. For older versions, use the command workflow's stdout redirection fallback. Compare the saved complete metadata, content, and decoded attachment bytes with the intended snapshot. For an update, check that its parent is the head used for preparation. Compact responses cannot verify omitted content.
66
+ Retain successful create, update, or edit stdout in a private receipt file. After create or update, read the explicit returned revision. Use `read ARTIFACT_ID --revision REVISION_ID --out FILE --quiet` to retain the full response without printing its snapshot. Check that the installed `--help` supports `--quiet` first. For older versions, use the command workflow's stdout redirection fallback. Compare the saved complete metadata, content, and decoded attachment bytes with the intended snapshot. For an update, check that its parent is the head used for preparation. Compact responses cannot verify omitted content.
67
67
 
68
68
  After publication, read current publication and compare revision, version, and URL with the committed result. A replayed receipt describes the original commit; another writer may have changed the current publication afterward. Report that difference.
69
69
 
70
+ For real panel and requested protected-publication checks, follow [the exact saved revision recipe](references/verify-retained.md). It prepares the existing verifier inputs from the write receipt and independent full read. It also gives an installed-browser route when the complete verifier runtime is unavailable. Report persistence, local rendering, panel rendering, and protected publication separately. Input preparation is not a browser pass.
71
+
70
72
  Follow list and history cursors when the request needs more than the first page. Export an explicit retained revision to the requested destination. The CLI checks the archive digest. Confirm that the file exists and inspect its manifest and relevant content before linking it.
71
73
 
72
74
  Report the title, saved revision, and draft or published state in the user's language. Link the actual export or returned protected publication URL. Distinguish persistence from any visual or browser checks that did not run. Keep local credential and session identifiers private.
@@ -61,7 +61,7 @@ Save this JSON as `snapshot.json`, replacing its content with the requested arti
61
61
  }
62
62
  ```
63
63
 
64
- `metadata.description` and `files` are required even when empty. Content kind is `html`, `markdown`, or `text`. Binary files use `encoding: "base64"` and canonical padded base64. Paths are unique normalized relative paths. Do not use empty, dot, parent, absolute, backslash, percent, or colon path segments. A file cannot also be another file's directory.
64
+ `metadata.description` and `files` are required even when empty. Content kind is `html`, `markdown`, `text`, or `design`. Binary files use `encoding: "base64"` and canonical padded base64. Paths are unique normalized relative paths. Do not use empty, dot, parent, absolute, backslash, percent, or colon path segments. A file cannot also be another file's directory.
65
65
 
66
66
  ```sh
67
67
  artifacts create --snapshot snapshot.json
@@ -75,6 +75,20 @@ For `read`, `--out FILE --quiet` saves the returned response without printing su
75
75
 
76
76
  Check installed help for `--quiet` support before running these examples. Older versions, including `0.1.0-beta.1`, can suppress stdout with `artifacts read ARTIFACT_ID --out saved.json > /dev/null`. This redirection preserves stderr and the command's exit status. Use the same fallback for the revision reads below.
77
77
 
78
+ ## Prepare a design canvas
79
+
80
+ Read the design skill's `references/canvas.md` through `artifacts skills get artifacts-design --full`. Keep the index in `my-canvas/canvas.json` and complete UTF-8 board documents in `my-canvas/boards/*.html`.
81
+
82
+ ```sh
83
+ artifacts skills path artifacts-cli
84
+ node <returned-skill-directory>/scripts/prepare-retained.mjs ./my-canvas > snapshot.json
85
+ artifacts create --snapshot snapshot.json
86
+ ```
87
+
88
+ The positional form accepts exactly one directory. It preserves the index text and board HTML, sorts snapshot files by path, validates the complete `design` snapshot with the installed contracts, and rejects missing or orphan boards. It uses the directory basename as the title and an empty description. Edit the emitted metadata before saving when needed. Keep the script under the path returned by `skills path` so its package dependencies resolve. All board data, embedded fonts, index text, and metadata share the 1 MiB budget.
89
+
90
+ The existing named-option form of `prepare-retained.mjs` builds a private retained-verification config from saved receipts and an independent read. It does not prepare a content snapshot. The two input forms cannot be combined.
91
+
78
92
  ## Organize projects
79
93
 
80
94
  Check that installed help lists `projects` and `move` before using them.
@@ -179,6 +193,10 @@ Share with `--notify` to request one email attempt. Access succeeds when mail is
179
193
 
180
194
  Owners can reserve 100 invitations per UTC day. Revoke, deletion, and failed delivery do not refund reservations. New access is limited to 100 people per artifact and 1000 grants per owner. Existing access remains listable and revocable.
181
195
 
196
+ ## Verify the exact saved revision
197
+
198
+ Follow [the retained revision recipe](verify-retained.md) after successful authoring and requested publication. Retain each successful write stdout in a private receipt file. The recipe extracts its IDs, performs a full independent explicit-revision read, and prepares the existing verifier config without authoring or account mutations. It includes an installed-browser path and identifies the additional capabilities for complete verification.
199
+
182
200
  ## Retry and export
183
201
 
184
202
  ```sh
@@ -0,0 +1,119 @@
1
+ # Verify the saved revision in the panel
2
+
3
+ Use the successful create, update, or edit result and an independent full read to select the exact saved revision. Use an authorized browser session to inspect its real panel and, when requested, its protected publication. Preparation alone does not prove persistence or browser behavior.
4
+
5
+ ## Retain the authoring results
6
+
7
+ Before the authorized write, use `umask 077` and save successful stdout to a new private receipt file. For example, add `> write-result.json` to the create or update command you already intend to run. Keep stderr and the retained request ID for recovery. Continue only after exit zero. Do not repeat a write to obtain a receipt after an uncertain response. Follow [retry recovery](commands.md#retry-and-export).
8
+
9
+ If publication was requested, also retain the successful publish stdout as `publish-result.json`. Finish that authorized publication before the independent read below. Do not publish, share, or revoke access merely to verify an artifact.
10
+
11
+ ## Prepare the exact selection
12
+
13
+ Use Node 24+ and a CLI whose help lists `skills path` and `read --quiet`. For older read output, use the [stdout fallback](commands.md#create-a-complete-snapshot). The installed helper requires only Node built-ins. It makes no service request and does not open a browser.
14
+
15
+ Set `observed_panel_origin` to the panel origin observed in the authorized browser or supplied service configuration. Do not derive it from `ARTIFACTS_SERVER` or the publication host. Replace the example origin before execution.
16
+
17
+ ```sh
18
+ umask 077
19
+ observed_panel_origin=https://panel.example.test
20
+ skill_directory=$(artifacts skills path artifacts-cli)
21
+ artifact_id=$(node -e '
22
+ try {
23
+ const id = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).artifactId;
24
+ if (typeof id !== "string" || id.length === 0) throw new Error();
25
+ process.stdout.write(id);
26
+ } catch {
27
+ console.error("Cannot extract the artifact ID from the write receipt");
28
+ process.exitCode = 1;
29
+ }
30
+ ' write-result.json)
31
+ revision_id=$(node -e '
32
+ try {
33
+ const id = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).revision.id;
34
+ if (typeof id !== "string" || id.length === 0) throw new Error();
35
+ process.stdout.write(id);
36
+ } catch {
37
+ console.error("Cannot extract the revision ID from the write receipt");
38
+ process.exitCode = 1;
39
+ }
40
+ ' write-result.json)
41
+ artifacts read "$artifact_id" --revision "$revision_id" --response full --out saved-read.json --quiet
42
+ ```
43
+
44
+ Stop if any extraction or read fails. A compact write receipt can select a revision. The independent read must contain the complete snapshot. Compare its complete metadata, content, and decoded attachment bytes with the intended snapshot. For an update, check the saved parent against the head used to prepare that update.
45
+
46
+ Save `checks.json` with the requested viewport sizes and meaningful expectations for this document. Replace the example selectors and values with intended content behavior. Expectations run in order inside the content iframe after a fresh load at each viewport.
47
+
48
+ ```json
49
+ {
50
+ "viewports": [{ "width": 1440, "height": 900 }, { "width": 390, "height": 844 }],
51
+ "expectations": [
52
+ { "kind": "text", "selector": "#total", "text": "5.00" },
53
+ { "kind": "click", "selector": "#change" },
54
+ { "kind": "text", "selector": "#total", "text": "28.40" }
55
+ ],
56
+ "timeoutMs": 10000
57
+ }
58
+ ```
59
+
60
+ Use `text` for exact rendered text, `visible` for visibility, `count` for an element count, or `attribute` for an exact attribute value. Use `name` and `value` with `attribute`. Interactions also support `fill` with `value`, `check` with `checked`, and `select` with a `values` array. Do not use arbitrary JavaScript or target panel controls in these expectations.
61
+
62
+ Prepare a new private config with a future evidence directory that does not yet exist.
63
+
64
+ ```sh
65
+ node "$skill_directory/scripts/prepare-retained.mjs" \
66
+ --receipt write-result.json --read saved-read.json \
67
+ --panel-origin "$observed_panel_origin" --checks checks.json \
68
+ --evidence "$PWD/new-retained-evidence" --out retained-config.json
69
+ ```
70
+
71
+ When publication was requested, add `--publication-receipt publish-result.json` to that invocation. The helper compares its artifact, revision, version, and URL with the current publication in the saved read. A different publication or concurrent change fails preparation. Report the difference. Do not replace expected versions or publish again to make verification pass.
72
+
73
+ The helper derives all selected IDs and URLs from these inputs. It accepts a newer draft head while keeping the saved revision pinned and reports `headChanged: true`. It emits `status: "prepared"`, never a verification pass. Without a publication receipt, it omits publication coverage even when another revision is published. Existing output files are preserved.
74
+
75
+ ## Inspect with an authorized browser
76
+
77
+ This route works with the installed CLI, its bundled files, and browser-control capabilities. It requires no repository checkout. Use a user-supplied session or complete the normal sign-in flow. CLI OAuth login does not supply panel cookies. Keep browser authentication inside the browser. Never extract credentials to construct a Playwright context or state file.
78
+
79
+ After sign-in setup, perform these read-only checks on the selected artifact. Do not create revisions, publish, share, log out, or revoke sessions. Normal protected-viewer admission can consume its one-use grant through the existing GET handoff.
80
+
81
+ 1. Read `retained-config.json` privately and open its exact `panelUrl`. Compare the artifact, revision, digest, and publication version with `expected`. Current panels expose `data-artifact-id`, `data-revision-id`, `data-revision-sha256`, and `data-publication-version`. Version zero means no publication. Report a newer head separately. Never switch the selected revision to the head.
82
+ 2. Establish the actual browser version and available frame, response, console, download, and viewport controls. Record missing capabilities. A browser name, load event, or empty console does not establish that content loaded.
83
+ 3. Inspect the actual preview iframe source, pinned revision, sandbox, referrer policy, and dimensions. Wait for a distinctive expected element within the configured deadline. Exercise each authorized content control and record the resulting visible state. Repeat at each available requested viewport and check keyboard interactions and overflow where relevant.
84
+ 4. When response inspection or native DevTools is available, inspect the iframe request. Record completion, status, content type, CSP, connection allowlist, cache policy, and DNS policy. Distinguish interactive content, inert fallback, and error responses. A pending request has no final response evidence. A top-level preview navigation deliberately returns 403 and cannot replace this iframe check.
85
+ 5. When the config includes `publicationUrl`, open that separate protected viewer through normal admission. Compare its selected revision, digest, and version with `expected`. Observe the real content and repeat the authorized interactions. Open `Archivos` and compare the complete attachment inventory and revision-bound links. Check protected download bytes when that capability is available. Missing attachment or response evidence remains unavailable.
86
+ 6. After publication interactions, reread the same explicit revision with the quoted IDs into a new private file. Compare its current `publication.revisionId`, `version`, and `url` with the saved read and publish receipt. Reload the selected panel and compare its publication markers again. Report a changed publication as a concurrent change. Do not report protected-publication success for a different selected revision.
87
+ 7. If the frame is blank, distinguish admission, pending transport, policy, browser compatibility, content execution, and browser-control failures from observed evidence. If debugger attachment stalls it, disconnect through normal browser controls, reload the same pinned page, and repeat the interaction with native controls. Preserve authentication and containment. Attribute a debugger failure only when that comparison demonstrates it.
88
+ 8. Save a sanitized report and private screenshots. Include selected IDs, digest, browser version, viewport sizes, actions, resulting visible states, observed response policies, and unavailable checks. Exclude cookies, tokens, viewer grants, callback queries, raw authentication requests, and private content from diagnostic logs.
89
+
90
+ This is partial browser verification. Do not return the complete verifier's pass verdict. Missing exact bytes, ZIP, policy, attachment, console, or viewport evidence must remain explicit.
91
+
92
+ ## Run the existing complete verifier when available
93
+
94
+ The CLI package does not install Playwright, `tsx`, Chrome, or the repository's verifier and rendering-policy modules. Complete automated verification needs an operator-provided installation of that existing verifier with its dependencies and actual supported Chrome. It also needs an explicitly supplied authenticated Playwright `BrowserContext` or owner-private storage-state file prepared through an approved sign-in flow.
95
+
96
+ For repository operators, run this command from the repository root. The paths in this command are repository capabilities, not paths inside an installed CLI package.
97
+
98
+ ```sh
99
+ node --import tsx .agents/skills/verify-artifacts/scripts/verify-retained.ts \
100
+ --config /private/path/retained-config.json \
101
+ --storage-state /private/path/browser-state.json
102
+ ```
103
+
104
+ Pass the prepared config unchanged. The existing verifier validates the full schema and canonical snapshot hash. It checks pinned responses, markers, policies, interactions, ZIP, attachment bytes, viewports, and publication state before and after viewer checks. It writes private evidence and fails on mismatches or concurrent publication changes. Use its callable `verifyRetainedArtifact(context, config)` when an authorized context is supplied. Do not build another viewer or weaken sandbox policies to establish deployed behavior.
105
+
106
+ If this runtime or authenticated input is unavailable, use the partial route above and name that limit. If browser access is unavailable too, report only the persistence checks actually completed.
107
+
108
+ ## Deliver the observed outcomes
109
+
110
+ Report each outcome separately, with its evidence and missing checks.
111
+
112
+ | Outcome | Evidence needed |
113
+ | --- | --- |
114
+ | Persistence | Full independent explicit-revision read and complete intended-snapshot comparison. Name any exact export check separately. |
115
+ | Local rendering | Observed local document layout and interactions. This does not prove hosted rendering. |
116
+ | Panel rendering | Exact real panel selection, pinned iframe, visible content, and observed interaction. Name missing response, policy, byte, console, or viewport checks. |
117
+ | Protected publication | Separate authorized viewer admission for the selected published revision, visible content, interactions, and attachment checks. Report concurrent changes and unavailable checks. |
118
+
119
+ Use `passed`, `failed`, `unavailable`, or `not requested` for each outcome as the evidence permits. Keep session identifiers and private evidence private. A successful helper invocation establishes only input preparation.
@@ -0,0 +1,149 @@
1
+ import { lstat, readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ function requireValue(condition, message) {
7
+ if (!condition) throw new Error(message);
8
+ }
9
+
10
+ function object(value) {
11
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
12
+ }
13
+
14
+ function prepareRetainedSelection({ receipt, expected, checks, panelOrigin, evidenceDirectory, publicationReceipt }) {
15
+ requireValue(object(receipt) && object(receipt.revision), 'Write receipt must identify a saved revision');
16
+ requireValue(
17
+ typeof receipt.artifactId === 'string' && receipt.artifactId.length > 0 &&
18
+ typeof receipt.revision.id === 'string' && receipt.revision.id.length > 0 &&
19
+ /^[a-f0-9]{64}$/.test(receipt.revision.sha256),
20
+ 'Write receipt must contain artifact, revision, and SHA-256',
21
+ );
22
+ const snapshot = expected?.revision?.snapshot;
23
+ requireValue(
24
+ object(expected) && object(snapshot) && object(snapshot.metadata) &&
25
+ typeof snapshot.metadata.title === 'string' && typeof snapshot.metadata.description === 'string' &&
26
+ object(snapshot.content) && ['html', 'markdown', 'text', 'design'].includes(snapshot.content.kind) &&
27
+ typeof snapshot.content.text === 'string' && Array.isArray(snapshot.files),
28
+ 'Independent read must contain the full snapshot, not a compact response',
29
+ );
30
+ requireValue(expected.artifactId === receipt.artifactId && expected.revision.id === receipt.revision.id && expected.revision.sha256 === receipt.revision.sha256, 'Independent read differs from the saved write receipt');
31
+ requireValue(typeof expected.headRevisionId === 'string' && expected.headRevisionId.length > 0, 'Independent read must identify its current draft head');
32
+ requireValue(object(checks) && Object.keys(checks).every((key) => ['viewports', 'expectations', 'timeoutMs'].includes(key)) && Array.isArray(checks.viewports) && checks.viewports.length > 0 && Array.isArray(checks.expectations), 'Checks must contain viewports and expectations, with optional timeoutMs');
33
+ let panel;
34
+ try { panel = new URL(panelOrigin); } catch { throw new Error('Panel origin must be an observed HTTP origin'); }
35
+ requireValue(['http:', 'https:'].includes(panel.protocol) && !panel.username && !panel.password && panel.pathname === '/' && !panel.search && !panel.hash, 'Panel origin must be an observed HTTP origin without a path, credentials, query, or fragment');
36
+ panel.pathname = `/artifacts/${encodeURIComponent(expected.artifactId)}`;
37
+ panel.searchParams.set('revisionId', expected.revision.id);
38
+ let publicationUrl;
39
+ if (publicationReceipt !== undefined) {
40
+ const current = expected.publication;
41
+ requireValue(
42
+ object(publicationReceipt) && object(current) &&
43
+ publicationReceipt.artifactId === expected.artifactId &&
44
+ publicationReceipt.revisionId === expected.revision.id && publicationReceipt.revisionId === current.revisionId &&
45
+ Number.isInteger(publicationReceipt.version) && publicationReceipt.version > 0 && publicationReceipt.version === current.version &&
46
+ typeof publicationReceipt.url === 'string' && publicationReceipt.url === current.url,
47
+ 'Publication differs from the requested receipt or selected revision; report the changed or different publication',
48
+ );
49
+ let publication;
50
+ try { publication = new URL(current.url); } catch { throw new Error('Publication receipt must contain a stable HTTP URL'); }
51
+ requireValue(['http:', 'https:'].includes(publication.protocol) && !publication.username && !publication.password && !publication.search && !publication.hash && publication.pathname === `/a/${encodeURIComponent(expected.artifactId)}`, 'Publication receipt must contain the selected artifact stable HTTP URL');
52
+ publicationUrl = publication.href;
53
+ }
54
+ return {
55
+ expected,
56
+ panelUrl: panel.href,
57
+ ...(publicationUrl === undefined ? {} : { publicationUrl }),
58
+ ...checks,
59
+ evidenceDirectory: resolve(evidenceDirectory),
60
+ };
61
+ }
62
+
63
+ async function canvasContracts() {
64
+ const name = '@yargote/artifacts-contracts';
65
+ let path;
66
+ try { path = createRequire(import.meta.url).resolve(name); }
67
+ catch (error) {
68
+ if (error.code !== 'MODULE_NOT_FOUND') throw error;
69
+ path = createRequire(new URL('../../../apps/cli/package.json', import.meta.url)).resolve(name);
70
+ }
71
+ return import(pathToFileURL(path).href);
72
+ }
73
+
74
+ async function prepareCanvasDirectory(input) {
75
+ const directory = resolve(input);
76
+ requireValue((await lstat(directory)).isDirectory(), 'Canvas input must be a directory');
77
+ const { canvasIndexSchema, snapshotSchema } = await canvasContracts();
78
+ const indexFile = join(directory, 'canvas.json');
79
+ const indexInfo = await lstat(indexFile);
80
+ requireValue(indexInfo.isFile(), 'canvas.json must be a regular file');
81
+ requireValue(indexInfo.size <= 1_048_576, 'canvas.json exceeds the shared 1 MiB revision budget');
82
+ const indexBytes = await readFile(indexFile);
83
+ const text = indexBytes.toString('utf8');
84
+ requireValue(Buffer.from(text, 'utf8').equals(indexBytes), 'canvas.json must contain valid UTF-8');
85
+ const index = canvasIndexSchema.parse(JSON.parse(text));
86
+ const boardDirectory = join(directory, 'boards');
87
+ requireValue((await lstat(boardDirectory)).isDirectory(), 'boards must be a directory, not a symbolic link');
88
+ const entries = await readdir(boardDirectory, { withFileTypes: true });
89
+ for (const entry of entries) {
90
+ const path = `boards/${entry.name}`;
91
+ requireValue(entry.isFile(), `Canvas board ${path} must be a regular file`);
92
+ requireValue(Object.hasOwn(index.boards, path), `Orphan canvas board ${path} is absent from canvas.json`);
93
+ }
94
+ const files = [];
95
+ for (const path of [...index.order].sort()) {
96
+ const filename = join(directory, path);
97
+ let info;
98
+ try { info = await lstat(filename); }
99
+ catch (error) {
100
+ if (error.code === 'ENOENT') throw new Error(`Canvas board ${path} has no matching file`);
101
+ throw error;
102
+ }
103
+ requireValue(info.isFile(), `Canvas board ${path} must be a regular file`);
104
+ requireValue(info.size <= 1_048_576, `Canvas board ${path} exceeds the shared 1 MiB revision budget`);
105
+ const bytes = await readFile(filename);
106
+ const data = bytes.toString('utf8');
107
+ requireValue(Buffer.from(data, 'utf8').equals(bytes), `Canvas board ${path} must contain valid UTF-8`);
108
+ files.push({ path, mediaType: 'text/html', encoding: 'utf8', data });
109
+ }
110
+ return snapshotSchema.parse({
111
+ metadata: { title: basename(directory), description: '' },
112
+ content: { kind: 'design', text },
113
+ files,
114
+ });
115
+ }
116
+
117
+ const required = ['--receipt', '--read', '--panel-origin', '--checks', '--evidence', '--out'];
118
+ try {
119
+ const args = process.argv.slice(2);
120
+ if (args.length === 1 && !args[0].startsWith('--')) {
121
+ console.log(JSON.stringify(await prepareCanvasDirectory(args[0]), null, 2));
122
+ } else {
123
+ const options = new Map();
124
+ requireValue(args.length % 2 === 0, 'Use named options with one value each');
125
+ for (let index = 0; index < args.length; index += 2) {
126
+ const name = args[index];
127
+ requireValue([...required, '--publication-receipt'].includes(name) && !options.has(name) && typeof args[index + 1] === 'string' && args[index + 1].length > 0, 'Use each supported option once with a nonempty value');
128
+ options.set(name, args[index + 1]);
129
+ }
130
+ requireValue(required.every((name) => options.has(name)), 'Required options are --receipt --read --panel-origin --checks --evidence --out');
131
+ const json = async (name) => {
132
+ try { return JSON.parse(await readFile(options.get(name), 'utf8')); }
133
+ catch { throw new Error(`Cannot read valid JSON for ${name}`); }
134
+ };
135
+ const receipt = await json('--receipt');
136
+ const expected = await json('--read');
137
+ const checks = await json('--checks');
138
+ const publication = options.has('--publication-receipt') ? await json('--publication-receipt') : undefined;
139
+ const config = prepareRetainedSelection({ receipt, expected, checks, panelOrigin: options.get('--panel-origin'), evidenceDirectory: options.get('--evidence'), publicationReceipt: publication });
140
+ try { await writeFile(options.get('--out'), `${JSON.stringify(config, null, 2)}\n`, { flag: 'wx', mode: 0o600 }); }
141
+ catch (error) { throw new Error(error?.code === 'EEXIST' ? 'Config output already exists; choose a new output path' : 'Cannot create the private config output'); }
142
+ const headChanged = expected.headRevisionId !== expected.revision.id;
143
+ console.log(JSON.stringify({ status: 'prepared', headChanged, publication: publication === undefined ? 'not-requested' : 'requested' }));
144
+ if (headChanged) console.error('Current draft head differs from the selected saved revision. The selection remains pinned.');
145
+ }
146
+ } catch (error) {
147
+ console.error(error.message);
148
+ process.exitCode = 1;
149
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: artifacts-design
3
- description: Design the HTML document of a retained Artifact with embedded fonts, light and dark Catppuccin tokens, purposeful layout, and rendered checks. Use before writing or restyling HTML for the artifacts or artifacts-cli skills.
3
+ description: Design HTML documents and multi-board design canvases of a retained Artifact with embedded fonts, light and dark Catppuccin tokens, purposeful layout, and rendered checks. Use before writing or restyling HTML or design boards for the artifacts or artifacts-cli skills.
4
4
  ---
5
5
 
6
6
  # Design HTML artifacts
@@ -9,6 +9,8 @@ The calling skill owns the snapshot, size limit, and save workflow. This skill o
9
9
 
10
10
  The document renders in a sandbox that blocks every network request. Fonts, images, and icons load only as `data:` URIs. External stylesheets, Google Fonts links, and CDN scripts silently fail. Inline all CSS and JavaScript.
11
11
 
12
+ For multiple boards, read [canvas authoring](references/canvas.md) before writing the index or HTML. It defines the board files, pages, geometry, shared font budget, and complete snapshot workflow.
13
+
12
14
  ## Choose the composition
13
15
 
14
16
  Before writing HTML, identify the reader's task, the facts and assets available, and what the reader should understand or do first. Use supplied facts. Label illustrative inputs and assumptions, and leave unavailable evidence absent.
@@ -49,7 +51,7 @@ After the HTML file is complete, embed the fonts it names:
49
51
  node <skill-directory>/scripts/embed-fonts.mjs page.html
50
52
  ```
51
53
 
52
- For the CLI, `<skill-directory>` is the output of `artifacts skills path artifacts-design`. The script rewrites the file in place and is safe to rerun after edits. It embeds only the families the page names and adds italic faces only for `font-style: italic`, `<em>`, `<i>`, or `<cite>`. It refuses to write a page above 1 MiB. Three families add about 135 KB. Newsreader adds about 80 to 165 KB. Do not name another family unless you embed its `woff2` yourself, because an unembedded face silently falls back. Without Node, keep the fallback stacks and report that the fonts were not embedded.
54
+ For the CLI, `<skill-directory>` is the output of `artifacts skills path artifacts-design`. The script rewrites the file in place and is safe to rerun after edits. It selects families from CSS `font-family` and `font` declarations in style elements and inline styles, including referenced custom properties. Comments, content text, and JavaScript strings do not select fonts. It adds italic faces for CSS `font-style: italic`, italic `font` shorthand, or real `<em>`, `<i>`, and `<cite>` elements. It refuses to write a page above 1 MiB. Three families add about 135 KB. Newsreader adds about 80 to 165 KB. Do not name another family unless you embed its `woff2` yourself, because an unembedded face silently falls back. Without Node, keep the fallback stacks and report that the fonts were not embedded.
53
55
 
54
56
  ## Color
55
57
 
@@ -77,6 +79,14 @@ Give the `<title>` a specific name of two to four words, or the name the user al
77
79
 
78
80
  ## Check the rendered page
79
81
 
82
+ Run the local helper after embedding fonts and before saving:
83
+
84
+ ```sh
85
+ node <skill-directory>/scripts/verify-local.mjs page.html --evidence ./page-evidence
86
+ ```
87
+
88
+ For the CLI, resolve `<skill-directory>` with `artifacts skills path artifacts-design`. Read [local checks](references/local-checks.md) for existing runtime requirements, optional declarative control expectations, browser selection, and report fields. The helper never installs a dependency, changes the HTML, saves, publishes, or requires service credentials. Its report proves **renderizado local** and leaves visual review required. It does not verify the retained revision or authenticated publication.
89
+
80
90
  Run the [interface checks](references/interface-checks.md) before saving. They distinguish required checks from visual judgment. Render the finished page at 375 px and 1280 px in both light and dark when browser tools are available. Fix observed failures and repeat the affected checks. Report checks you could not run.
81
91
 
82
92
  The license notices for bundled fonts and adapted guidance are in [third-party notices](THIRD-PARTY-NOTICES.md).
@@ -0,0 +1,100 @@
1
+ # Author a design canvas
2
+
3
+ Create a retained `design` snapshot when several HTML boards belong in one canvas. Each board is a complete self-contained HTML document. The canvas supplies placement, page selection, notes, pan, zoom, and a focus view. The calling MCP or CLI skill owns saving and publication.
4
+
5
+ ## Compose the boards
6
+
7
+ 1. Choose a frame size for each board. Common starting sizes are 1440 by 900 for desktop, 390 by 844 for a phone, and 768 by 1024 for a tablet. These are authoring conventions, not schema limits.
8
+ 2. Place boards in a row with 80 px between their edges. Start another row 120 px below the tallest board in the preceding row. Coordinates are canvas pixels, and boards can use different sizes.
9
+ 3. Write each board under `boards/` as one UTF-8 `.html` file. Inline its styles, scripts, images, and fonts. Files retained beside a board are downloads, not relative executable assets.
10
+ 4. Apply this skill's typography and Catppuccin tokens inside each board. The canvas ground follows the viewer's light or dark theme independently.
11
+ 5. Define `canvas.json` with the exact index below. Keep snapshot metadata outside the index.
12
+
13
+ ```json
14
+ {
15
+ "v": 1,
16
+ "boards": {
17
+ "boards/Desktop.html": { "x": 0, "y": 0, "w": 1440, "h": 900, "title": "Desktop", "page": "overview" },
18
+ "boards/Phone.html": { "x": 1520, "y": 0, "w": 390, "h": 844, "title": "Phone", "page": "overview" },
19
+ "boards/Detail.html": { "x": 0, "y": 0, "w": 1440, "h": 900, "title": "Detail", "page": "detail" },
20
+ "boards/DetailPhone.html": { "x": 1520, "y": 0, "w": 390, "h": 844, "title": "Detail phone", "page": "detail" }
21
+ },
22
+ "order": ["boards/Desktop.html", "boards/Phone.html", "boards/Detail.html", "boards/DetailPhone.html"],
23
+ "notes": {
24
+ "review": { "x": 0, "y": 1020, "w": 390, "text": "Review both layouts.", "page": "overview" }
25
+ },
26
+ "pages": [
27
+ { "id": "overview", "name": "Overview" },
28
+ { "id": "detail", "name": "Detail" }
29
+ ]
30
+ }
31
+ ```
32
+
33
+ The first page opens initially. Boards and notes without `page` appear on the first page when pages exist. Without pages, every board and note appears together. A page can reuse the coordinates of another page.
34
+
35
+ ## Index reference
36
+
37
+ The index and its nested entries reject unknown fields. The complete rules are:
38
+
39
+ | Field | Rule |
40
+ | --- | --- |
41
+ | `v` | Required integer `1`. |
42
+ | `boards` | Required object with at most 32 entries. Every key matches `^boards/[A-Za-z0-9_][A-Za-z0-9_.-]*\.html$`. Names are case-sensitive and flat. |
43
+ | Board `x`, `y` | Required finite numbers from -1,000,000 to 1,000,000 inclusive. Fractions are accepted. |
44
+ | Board `w`, `h` | Required finite numbers from 40 to 8000 inclusive. Fractions are accepted. |
45
+ | Board `title` | Optional string of 1 to 256 characters. The board path is the display fallback. |
46
+ | Board `page` | Optional ID that names a declared page. |
47
+ | `order` | Required array of 1 to 32 board paths. It names every board exactly once and contains no other path. Its order controls rendering. |
48
+ | `notes` | Optional object with at most 200 entries. Each key is an ID. |
49
+ | Note `x`, `y`, `text` | Coordinates use the board coordinate limits. `text` is a required string rendered as escaped text, not HTML. |
50
+ | Note `w`, `page` | Optional width from 40 to 8000 and an optional declared page ID. |
51
+ | `pages` | Optional array of at most 32 objects with required `id` and `name`. IDs are unique. `name` contains 1 to 256 characters. |
52
+ | ID | 1 to 40 ASCII letters, digits, underscores, or hyphens. `__proto__` is rejected. |
53
+
54
+ Every indexed board has a matching snapshot file with the same `path`, `mediaType: "text/html"`, `encoding: "utf8"`, and the complete HTML in `data`. A snapshot has at most 32 files total, including other retained downloads. The directory preparer rejects every unindexed entry in `boards/`, missing boards, directories, symbolic links, and invalid UTF-8. The snapshot contract itself permits other unindexed attachments.
55
+
56
+ ## Embed fonts within the shared budget
57
+
58
+ Run the real font helper once for each board after completing its HTML:
59
+
60
+ ```sh
61
+ node <design-skill-directory>/scripts/embed-fonts.mjs my-canvas/boards/Desktop.html
62
+ node <design-skill-directory>/scripts/embed-fonts.mjs my-canvas/boards/Phone.html
63
+ ```
64
+
65
+ For the CLI, resolve `<design-skill-directory>` with `artifacts skills path artifacts-design`. The helper selects bundled Latin font subsets from actual CSS declarations and embeds `data:` WOFF2 faces. It rewrites the board in place and is safe to rerun.
66
+
67
+ Use one small, consistent selection of bundled families and styles across boards. Each board needs its own embedded font bytes because each iframe is an independent document. A subset cannot be shared through an attachment URL. Three families add about 135 KB per board, so repeated embedding can exhaust the canvas budget. Prefer one family and only the styles the board uses when many boards share one revision. The helper checks one board's 1 MiB limit. The preparer and server check the combined revision.
68
+
69
+ The shared limit is 1,048,576 decoded bytes. It includes serialized snapshot metadata, UTF-8 index text, every file path and media type, and every decoded file body. Whitespace in `canvas.json` counts. The 1 MiB limit applies to all boards together, not to each board separately.
70
+
71
+ ## Save the canvas
72
+
73
+ For MCP, send one complete snapshot to `artifacts_create` or `artifacts_update`:
74
+
75
+ ```js
76
+ {
77
+ metadata: { title: 'Product layouts', description: 'Desktop and phone review.' },
78
+ content: { kind: 'design', text: JSON.stringify(index) },
79
+ files: boardPaths.map((path) => ({ path, mediaType: 'text/html', encoding: 'utf8', data: boardHtml[path] }))
80
+ }
81
+ ```
82
+
83
+ Here `index`, `boardPaths`, and `boardHtml` come from your completed source files. Read the saved revision back through `artifacts_read` before reporting persistence.
84
+
85
+ For the CLI, keep `canvas.json` and `boards/*.html` in one directory. Run the bundled preparer:
86
+
87
+ ```sh
88
+ node <cli-skill-directory>/scripts/prepare-retained.mjs ./my-canvas > snapshot.json
89
+ artifacts create --snapshot snapshot.json
90
+ ```
91
+
92
+ Resolve `<cli-skill-directory>` with `artifacts skills path artifacts-cli`. Use the script at that returned path so its installed contracts dependency resolves. The preparer preserves the index text and board HTML and sorts snapshot files by path. Canvas rendering still follows `order`. It emits a validated snapshot on stdout, uses the directory basename as `metadata.title`, and sets `metadata.description` to `""`. Edit those metadata fields in the emitted JSON before saving when the task calls for a specific title or description. The directory form accepts exactly one positional argument. The named-option form prepares retained-verification configuration and keeps its separate contract.
93
+
94
+ ## Check the retained canvas
95
+
96
+ Open the exact saved revision in the authenticated panel. Confirm all boards fit on the initial page, select each page, and open a board through its name button. Focus view shows that board at its declared width and height and enables its controls. **Volver al lienzo** returns to the prior pan and zoom. Escape also returns when a canvas control has keyboard focus. After interacting inside a board, use **Volver al lienzo** to return. For Escape, first use Shift+Tab to move focus back to the canvas controls. Embedded boards remain inert in the overview.
97
+
98
+ Check publication through the protected URL returned by `artifacts_publish`. In unsupported browsers, the canvas displays an inert listing of board source. Rendering uses nested opaque `srcdoc` iframes with `sandbox="allow-scripts"` and the existing content policy. Board scripts can still consume renderer time or briefly perturb focus. The canvas is not an absolute keyboard isolation boundary.
99
+
100
+ Export retains the index as `content.json` and boards as `files/boards/*.html`. To recreate a snapshot, use the manifest's `metadata`, preserve `content.json` as `content.text`, and reconstruct each file using its declared encoding. The ZIP manifest sorts files by path, matching the directory preparer. Snapshot digests preserve the original `files` array order, so keep that order from an independent saved read when comparing digests after recreation. A directory preparer also derives new metadata. Restore the manifest metadata and original file order before comparing snapshot digests.
@@ -0,0 +1,70 @@
1
+ # Check local HTML before saving
2
+
3
+ Run the helper after embedding fonts and before the calling skill's create or update operation.
4
+
5
+ ```sh
6
+ design_skill=$(artifacts skills path artifacts-design)
7
+ node "$design_skill/scripts/verify-local.mjs" page.html --evidence ./page-evidence
8
+ ```
9
+
10
+ The helper needs Node 24+, an existing `playwright` or `playwright-core` package, and Chrome or Chromium 152+. It resolves the package from the current project first, then from its own installation. It tries existing Playwright browser binaries and system executables. To select an existing browser explicitly, add `--browser /absolute/path/to/chromium`. An explicit selection does not fall back to another executable.
11
+
12
+ Missing dependencies, incompatible versions, and launch failures produce actionable diagnostics. The helper never installs software, downloads a browser, disables browser sandboxing, or relaxes document policies.
13
+
14
+ ## Describe controls
15
+
16
+ Add `--expectations checks.json` to replay actions and assert their results. Each matrix case starts with a fresh document. Steps run in order. A failed step ends that case's sequence because later steps may depend on it. The remaining cases still run.
17
+
18
+ ```json
19
+ {
20
+ "expectations": [
21
+ { "kind": "visibleCount", "selector": ".mobile-map, .desktop-map", "count": 1 },
22
+ { "kind": "visible", "selector": ".mobile-map", "visible": true, "when": { "width": 375 } },
23
+ { "kind": "visible", "selector": ".desktop-map", "visible": true, "when": { "width": 1280 } },
24
+ { "kind": "text", "selector": "#total", "text": "5.00" },
25
+ { "kind": "click", "selector": "#calculate" },
26
+ { "kind": "text", "selector": "#total", "text": "28.40" },
27
+ { "kind": "press", "selector": "#reset", "key": "Enter" },
28
+ { "kind": "text", "selector": "#total", "text": "5.00" }
29
+ ]
30
+ }
31
+ ```
32
+
33
+ Every step requires `kind` and a Playwright locator `selector`. Optional `when` limits it to `width` of 375 or 1280, `theme` of `light` or `dark`, or both. Unknown fields and malformed values fail configuration validation.
34
+
35
+ | Kind | Additional fields | Observable result |
36
+ | --- | --- | --- |
37
+ | `text` | `text`, a string | Exact inner text of one element. Add a `visible` check when visibility is required. |
38
+ | `count` | `count`, a nonnegative integer | Number of matching elements. |
39
+ | `visible` | `visible`, a boolean | Visibility of exactly one matching element. A missing element fails. |
40
+ | `visibleCount` | `count`, a nonnegative integer | Number of visible matches. Use one selector matching both responsive variants to assert exclusivity. |
41
+ | `attribute` | `name`, a nonempty string, and `value`, a string or `null` | Exact attribute value. `null` requires absence. |
42
+ | `click` | None. | Real pointer activation. |
43
+ | `press` | `key`, a Playwright key string such as `Enter`, `Space`, or `Tab` | Keyboard input to the selected element. |
44
+ | `fill` | `value`, a string | Input or textarea edit. |
45
+ | `check` | `checked`, a boolean | Checkbox or radio state. |
46
+ | `select` | `values`, an array of strings | Selected option values. |
47
+
48
+ Assertions poll for their expected result. Actions wait for Playwright's actionability checks. The JSON document accepts at most 100 steps and an optional `timeoutMs` from 100 to 30000. Its default is 3000 ms per expectation. With no expectations file, the helper checks rendering and errors but does not infer the document's intended controls.
49
+
50
+ Each variant also has a total deadline of `timeoutMs * (step count + 1)`, bounded between 10 and 60 seconds. The deadline includes loading, checks, captures, and final observations. If the renderer stops responding, the helper closes that variant's context, records `deadline.exceeded`, and marks its remaining evidence incomplete. The other variants still run. A deadline failure cannot pass the automatic checks.
51
+
52
+ ## Inspect the evidence
53
+
54
+ The helper creates a fresh `local-*` subdirectory under `--evidence`. It writes `report.json`, `index.md`, and PNG images. Repeated runs keep separate evidence.
55
+
56
+ The fixed matrix is 375 and 1280 px wide at 900 px high, each in light and dark. Every case checks document overflow before and after its scenario, runtime exceptions, error console entries, and forbidden resource attempts. CSP violations include resource attempts blocked before a browser request occurs. Initial and final captures scroll the actual iframe in viewport-sized steps. Each phase saves at most 12 tiles. Capture records include actual scroll offsets and mark incomplete coverage in `captureCoverage.truncated`.
57
+
58
+ The document runs in an opaque-origin iframe with `sandbox="allow-scripts"`. Its response uses the Artifacts preview CSP, `Connection-Allowlist: ()`, and disabled DNS prefetching. Fonts, images, and media must be embedded as `data:` URIs. Network connections, remote scripts, frames, objects, forms, and workers remain blocked. The local server exposes only the generated wrapper and content, without arbitrary filesystem access. Browser request routing also denies other destinations.
59
+
60
+ The report records the chosen executable, version, user agent, rejected browser candidates, source hashes before and after verification, actual content response policy and hash, per-case checks, errors, and screenshots. It identifies the evidence as **renderizado local** and leaves `authenticatedPublication` as `not-verified`. It does not modify the HTML, save a revision, publish, or contact the Artifacts service. No service credentials are required.
61
+
62
+ | Exit code | Meaning |
63
+ | --- | --- |
64
+ | 0 | Automatic checks passed. Visual review remains required. |
65
+ | 1 | An automatic check failed. |
66
+ | 2 | Setup or configuration blocked the run. |
67
+
68
+ A structured blocked report is written when a valid evidence destination is available. The helper prints the report as JSON on standard output.
69
+
70
+ Read the screenshots and the [interface checks](interface-checks.md) before accepting the design. Check content fidelity, reading order, labels, focus, alignment, density, and both themes. A clean console cannot establish those judgments. Each run observes load, font readiness, its declared interactions, and a short settling period. Delayed behavior beyond the run and content outside captured coverage require further inspection. Verify the retained revision and protected publication separately through the service workflow after saving.
@@ -14,7 +14,208 @@ const faces = [
14
14
  { family: 'Newsreader', style: 'italic', weight: '400 700', file: 'newsreader-italic.woff2' },
15
15
  ];
16
16
  const limit = 1024 * 1024;
17
- const embedded = /<style data-artifacts-fonts>[\s\S]*?<\/style>\n?/;
17
+
18
+ function htmlTypographySources(page) {
19
+ const sources = [];
20
+ const managed = [];
21
+ const tags = /<!--[\s\S]*?(?:-->|$)|<![^>]*>|<\/?([a-z][a-z0-9:-]*)(?=[\s/>])(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi;
22
+ let italic = false;
23
+ let anchor;
24
+ let head;
25
+ let match;
26
+ while ((match = tags.exec(page))) {
27
+ const [tag, name] = match;
28
+ if (tag.startsWith('<!--')) continue;
29
+ if (/^<!doctype\b/i.test(tag)) head ??= tags.lastIndex;
30
+ if (!name || tag.startsWith('</')) continue;
31
+ const lower = name.toLowerCase();
32
+ if (lower === 'head') head = tags.lastIndex;
33
+ const attributes = new Map();
34
+ const attribute = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
35
+ const entities = { quot: '"', apos: "'", amp: '&', lt: '<', gt: '>', sol: '/' };
36
+ for (const entry of tag.slice(name.length + 1, -1).matchAll(attribute)) {
37
+ const key = entry[1].toLowerCase();
38
+ const value = (entry[2] ?? entry[3] ?? entry[4] ?? '').replace(/&(#(?:[xX][\da-fA-F]+|\d+));?|&(quot|QUOT|apos|amp|AMP|lt|LT|gt|GT|sol);/g, (entity, numeric, named) => {
39
+ if (named) return entities[named.toLowerCase()];
40
+ const code = numeric[1].toLowerCase() === 'x' ? parseInt(numeric.slice(2), 16) : Number(numeric.slice(1));
41
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '\ufffd';
42
+ });
43
+ if (!attributes.has(key)) attributes.set(key, value);
44
+ }
45
+ if (attributes.has('style')) sources.push({ css: attributes.get('style'), inline: true });
46
+ if (['em', 'i', 'cite'].includes(lower)) italic = true;
47
+ if (lower === 'plaintext') break;
48
+ if (['style', 'script', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes'].includes(lower)) {
49
+ const close = new RegExp(`</${lower}\\s*>`, 'gi');
50
+ close.lastIndex = tags.lastIndex;
51
+ const end = close.exec(page);
52
+ const contentEnd = end?.index ?? page.length;
53
+ if (lower === 'style') {
54
+ if (attributes.has('data-artifacts-fonts')) {
55
+ let after = end ? close.lastIndex : page.length;
56
+ if (page[after] === '\n') after++;
57
+ managed.push([match.index, after]);
58
+ } else if (!attributes.get('type') || /^text\/css$/i.test(attributes.get('type'))) {
59
+ anchor ??= match.index;
60
+ sources.push({ css: page.slice(tags.lastIndex, contentEnd), inline: false });
61
+ }
62
+ }
63
+ tags.lastIndex = end ? close.lastIndex : page.length;
64
+ }
65
+ }
66
+ return { sources, managed, italic, insertionAt: anchor ?? head ?? 0 };
67
+ }
68
+
69
+ function cssDeclarations(css, inline) {
70
+ const declarations = [];
71
+ const blocks = [];
72
+ let segment = '';
73
+ let quote = '';
74
+ let parentheses = 0;
75
+ let brackets = 0;
76
+ let valueBraces = 0;
77
+ function emit() {
78
+ const match = /^\s*(--[\w-]+|[-a-z]+)\s*:\s*([\s\S]*)$/i.exec(segment);
79
+ if (match && (inline || blocks.length) && !blocks.includes(true)) {
80
+ declarations.push({ property: match[1].startsWith('--') ? match[1] : match[1].toLowerCase(), value: match[2].replace(/\s*!\s*important\s*$/i, '') });
81
+ }
82
+ segment = '';
83
+ }
84
+ for (let index = 0; index < css.length; index++) {
85
+ const char = css[index];
86
+ if (char === '\\') {
87
+ segment += css.slice(index, index + 2);
88
+ index++;
89
+ } else if (quote) {
90
+ segment += char;
91
+ if (char === quote) quote = '';
92
+ } else if (char === '"' || char === "'") {
93
+ quote = char;
94
+ segment += char;
95
+ } else if (char === '/' && css[index + 1] === '*') {
96
+ const end = css.indexOf('*/', index + 2);
97
+ index = end < 0 ? css.length : end + 1;
98
+ segment += ' ';
99
+ } else if (char === '(' || char === '[') {
100
+ if (char === '(') parentheses++;
101
+ else brackets++;
102
+ segment += char;
103
+ } else if (char === ')' || char === ']') {
104
+ if (char === ')') parentheses--;
105
+ else brackets--;
106
+ segment += char;
107
+ } else if (!parentheses && !brackets && char === '{') {
108
+ if (valueBraces || /^\s*--[\w-]+\s*:/.test(segment)) {
109
+ valueBraces++;
110
+ segment += char;
111
+ } else {
112
+ blocks.push(/^\s*@font-face\b/i.test(segment));
113
+ segment = '';
114
+ }
115
+ } else if (!parentheses && !brackets && char === '}' && valueBraces) {
116
+ valueBraces--;
117
+ segment += char;
118
+ } else if (!parentheses && !brackets && !valueBraces && (char === ';' || char === '}')) {
119
+ emit();
120
+ if (char === '}') blocks.pop();
121
+ } else segment += char;
122
+ }
123
+ emit();
124
+ return declarations;
125
+ }
126
+
127
+ function splitValue(value, separator) {
128
+ const parts = [];
129
+ let start = 0;
130
+ let depth = 0;
131
+ let quote = '';
132
+ for (let index = 0; index < value.length; index++) {
133
+ const char = value[index];
134
+ if (char === '\\') index++;
135
+ else if (quote) { if (char === quote) quote = ''; }
136
+ else if (char === '"' || char === "'") quote = char;
137
+ else if (char === '(') depth++;
138
+ else if (char === ')') depth--;
139
+ else if (!depth && separator.test(char)) {
140
+ parts.push(value.slice(start, index));
141
+ if (char === '/') parts.push('/');
142
+ start = index + 1;
143
+ }
144
+ }
145
+ parts.push(value.slice(start));
146
+ return parts;
147
+ }
148
+
149
+ function fontValueAlternatives(value, properties, visiting = new Set()) {
150
+ let quote = '';
151
+ for (let index = 0; index < value.length; index++) {
152
+ const char = value[index];
153
+ if (char === '\\') index++;
154
+ else if (quote) { if (char === quote) quote = ''; }
155
+ else if (char === '"' || char === "'") quote = char;
156
+ else if (/^var\(/i.test(value.slice(index, index + 4)) && !/[\w-]/.test(value[index - 1] ?? '')) {
157
+ let depth = 1;
158
+ let end = index + 4;
159
+ let innerQuote = '';
160
+ for (; end < value.length && depth; end++) {
161
+ const inner = value[end];
162
+ if (inner === '\\') end++;
163
+ else if (innerQuote) { if (inner === innerQuote) innerQuote = ''; }
164
+ else if (inner === '"' || inner === "'") innerQuote = inner;
165
+ else if (inner === '(') depth++;
166
+ else if (inner === ')') depth--;
167
+ }
168
+ if (depth) return [];
169
+ const [name, ...fallback] = splitValue(value.slice(index + 4, end - 1), /,/);
170
+ const key = name.trim();
171
+ const candidates = [];
172
+ if (!visiting.has(key)) {
173
+ const path = new Set(visiting).add(key);
174
+ for (const definition of properties.get(key) ?? []) candidates.push(...fontValueAlternatives(definition, properties, path));
175
+ }
176
+ if (fallback.length) candidates.push(...fontValueAlternatives(fallback.join(','), properties, visiting));
177
+ return [...new Set(candidates.flatMap((candidate) => fontValueAlternatives(value.slice(0, index) + candidate + value.slice(end), properties, visiting)))];
178
+ }
179
+ }
180
+ return [value];
181
+ }
182
+
183
+ function typographyUsage(document) {
184
+ const declarations = document.sources.flatMap(({ css, inline }) => cssDeclarations(css, inline));
185
+ const properties = new Map();
186
+ for (const { property, value } of declarations) {
187
+ if (property.startsWith('--')) properties.set(property, [...properties.get(property) ?? [], value]);
188
+ }
189
+ const usage = { families: new Set(), italic: document.italic };
190
+ for (const { property, value } of declarations) {
191
+ if (!['font-family', 'font', 'font-style'].includes(property)) continue;
192
+ for (let resolved of fontValueAlternatives(value, properties)) {
193
+ resolved = resolved.trim();
194
+ if (property === 'font-style') {
195
+ if (/^italic$/i.test(resolved)) usage.italic = true;
196
+ continue;
197
+ }
198
+ if (property === 'font') {
199
+ const tokens = splitValue(resolved, /[\s/]/).filter(Boolean);
200
+ const size = tokens.findIndex((token) => /^(?:\d*\.?\d+(?:[a-z]+|%)|0|xx?-small|small|medium|large|x{1,3}-large|smaller|larger)$/i.test(token) || /^(?:calc|min|max|clamp)\(/i.test(token));
201
+ if (size < 0) continue;
202
+ if (tokens.slice(0, size).some((token) => /^italic$/i.test(token))) usage.italic = true;
203
+ resolved = tokens.slice(size + (tokens[size + 1] === '/' ? 3 : 1)).join(' ');
204
+ }
205
+ for (let family of splitValue(resolved, /,/)) {
206
+ family = family.trim();
207
+ if (/^(["'])[\s\S]*\1$/.test(family)) family = family.slice(1, -1);
208
+ family = family.replace(/\\([\da-f]{1,6})(?:\r\n|[\t\n\r\f ])?|\\([^\r\n\f])/gi, (_, hex, escaped) => {
209
+ const code = parseInt(hex, 16);
210
+ return hex ? code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '\ufffd' : escaped;
211
+ }).replace(/\s+/g, ' ').toLowerCase();
212
+ const face = faces.find((candidate) => candidate.family.toLowerCase() === family);
213
+ if (face) usage.families.add(face.family);
214
+ }
215
+ }
216
+ }
217
+ return usage;
218
+ }
18
219
 
19
220
  const paths = process.argv.slice(2);
20
221
  if (paths.length !== 1) {
@@ -22,18 +223,18 @@ if (paths.length !== 1) {
22
223
  process.exit(2);
23
224
  }
24
225
  const [path] = paths;
25
- const page = (await readFile(path, 'utf8')).replace(embedded, '');
26
- const italic = /font-style\s*:\s*italic|<(em|i|cite)[\s>]/i.test(page);
27
- const used = faces.filter((face) => page.includes(face.family) && (face.style === 'normal' || italic));
226
+ let page = await readFile(path, 'utf8');
227
+ for (const [start, end] of htmlTypographySources(page).managed.reverse()) page = page.slice(0, start) + page.slice(end);
228
+ const document = htmlTypographySources(page);
229
+ const usage = typographyUsage(document);
230
+ const used = faces.filter((face) => usage.families.has(face.family) && (face.style === 'normal' || usage.italic));
28
231
  const rules = await Promise.all(used.map(async (face) => {
29
232
  const data = (await readFile(join(fonts, face.file))).toString('base64');
30
233
  return `@font-face{font-family:"${face.family}";font-style:${face.style};font-weight:${face.weight};font-display:swap;src:url(data:font/woff2;base64,${data}) format("woff2");unicode-range:${latin}}`;
31
234
  }));
32
235
  const block = rules.length ? `<style data-artifacts-fonts>${rules.join('')}</style>\n` : '';
33
236
 
34
- const anchor = page.search(/<style[\s>]/i);
35
- const head = /<head[^>]*>\n?/i.exec(page) ?? /<!doctype[^>]*>\n?/i.exec(page);
36
- const at = anchor >= 0 ? anchor : head ? head.index + head[0].length : 0;
237
+ const at = document.insertionAt;
37
238
  const output = page.slice(0, at) + block + page.slice(at);
38
239
  const bytes = Buffer.byteLength(output);
39
240
  if (bytes > limit) {
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict';
3
+ import { execFile } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import { once } from 'node:events';
6
+ import { access, mkdir, mkdtemp, readFile, readdir, realpath, writeFile } from 'node:fs/promises';
7
+ import { constants } from 'node:fs';
8
+ import { createServer } from 'node:http';
9
+ import { createRequire } from 'node:module';
10
+ import { homedir } from 'node:os';
11
+ import { delimiter, join, resolve, sep } from 'node:path';
12
+ import { promisify } from 'node:util';
13
+
14
+ const execute = promisify(execFile);
15
+ const framePolicy = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; frame-src 'none'; worker-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
16
+ const previewPolicy = `${framePolicy}; frame-ancestors 'self'; sandbox allow-scripts`;
17
+ const variants = [375, 1280].flatMap((width) => ['light', 'dark'].map((theme) => ({ width, height: 900, theme })));
18
+ const fields = { text: ['text'], count: ['count'], visible: ['visible'], visibleCount: ['count'], attribute: ['name', 'value'], click: [], press: ['key'], fill: ['value'], check: ['checked'], select: ['values'] };
19
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
20
+ const message = (error) => error instanceof Error ? error.message : String(error);
21
+ const escape = (value) => value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]);
22
+ const compatible = (ua) => !/Edg|OPR|SamsungBrowser|CriOS|FxiOS/.test(ua) && Number(/(?:Headless)?Chrome\/(\d+)\./.exec(ua)?.[1]) >= 152;
23
+
24
+ function object(value, required, optional, label) {
25
+ assert(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`);
26
+ assert(required.every((key) => Object.hasOwn(value, key)), `${label} has missing fields`);
27
+ assert(Object.keys(value).every((key) => required.includes(key) || optional.includes(key)), `${label} has unknown fields`);
28
+ return value;
29
+ }
30
+
31
+ function parseExpectations(value) {
32
+ object(value, ['expectations'], ['timeoutMs'], 'expectations document');
33
+ assert(Array.isArray(value.expectations) && value.expectations.length <= 100, 'expectations must contain at most 100 steps');
34
+ const timeoutMs = value.timeoutMs ?? 3000;
35
+ assert(Number.isInteger(timeoutMs) && timeoutMs >= 100 && timeoutMs <= 30000, 'timeoutMs must be an integer from 100 to 30000');
36
+ for (const step of value.expectations) {
37
+ assert(step && Object.hasOwn(fields, step.kind), 'Unknown expectation kind');
38
+ object(step, ['kind', 'selector', ...fields[step.kind]], ['when'], 'expectation');
39
+ assert(typeof step.selector === 'string' && step.selector.trim() && step.selector.length <= 8192, 'selector must be a nonempty string');
40
+ if (step.when !== undefined) {
41
+ object(step.when, [], ['width', 'theme'], 'when');
42
+ if (step.when.width !== undefined) assert([375, 1280].includes(step.when.width), 'when.width must be 375 or 1280');
43
+ if (step.when.theme !== undefined) assert(['light', 'dark'].includes(step.when.theme), 'when.theme must be light or dark');
44
+ }
45
+ for (const key of fields[step.kind]) {
46
+ if (['count'].includes(key)) assert(Number.isInteger(step[key]) && step[key] >= 0, 'count must be a nonnegative integer');
47
+ else if (['visible', 'checked'].includes(key)) assert(typeof step[key] === 'boolean', `${key} must be boolean`);
48
+ else if (key === 'values') assert(Array.isArray(step.values) && step.values.length <= 100 && step.values.every((entry) => typeof entry === 'string'), 'values must be strings');
49
+ else if (key === 'value' && step.kind === 'attribute') assert(step.value === null || typeof step.value === 'string', 'attribute value must be a string or null');
50
+ else assert(typeof step[key] === 'string' && (!['key', 'name'].includes(key) || step[key].trim()), `${key} must be a string`);
51
+ }
52
+ }
53
+ return { expectations: value.expectations, timeoutMs };
54
+ }
55
+
56
+ function invocation(args) {
57
+ const options = {};
58
+ let html;
59
+ for (let index = 0; index < args.length; index++) {
60
+ const arg = args[index];
61
+ if (arg.startsWith('--')) {
62
+ assert(['--evidence', '--expectations', '--browser'].includes(arg), `Unknown option ${arg}`);
63
+ assert(!Object.hasOwn(options, arg) && args[index + 1] && !args[index + 1].startsWith('--'), `Supply ${arg} once with a value`);
64
+ options[arg] = args[++index];
65
+ } else { assert(!html, 'Supply one HTML file'); html = arg; }
66
+ }
67
+ assert(html && options['--evidence'], 'Usage: verify-local.mjs HTML --evidence DIRECTORY [--expectations JSON] [--browser EXECUTABLE]');
68
+ return { html: resolve(html), evidence: resolve(options['--evidence']), expectations: options['--expectations'] && resolve(options['--expectations']), browser: options['--browser'] && resolve(options['--browser']) };
69
+ }
70
+
71
+ function playwright() {
72
+ for (const base of [join(process.cwd(), 'package.json'), import.meta.url]) {
73
+ const require = createRequire(base);
74
+ for (const name of ['playwright', 'playwright-core']) {
75
+ try { return require(name).chromium; }
76
+ catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error; }
77
+ }
78
+ }
79
+ throw new Error('No Playwright runtime found. Provide an existing playwright or playwright-core package in the working directory project, then rerun. The helper does not install packages or browsers.');
80
+ }
81
+
82
+ async function cachedExecutables(chromium) {
83
+ const expected = chromium.executablePath();
84
+ const browserDirectory = /[\\/](?:chromium|chromium_headless_shell)-\d+[\\/]/.exec(expected);
85
+ const roots = new Set([browserDirectory && expected.slice(0, browserDirectory.index), process.env.PLAYWRIGHT_BROWSERS_PATH && process.env.PLAYWRIGHT_BROWSERS_PATH !== '0' && resolve(process.env.PLAYWRIGHT_BROWSERS_PATH)].filter(Boolean));
86
+ const executables = [];
87
+ for (const root of roots) {
88
+ let entries;
89
+ try { entries = await readdir(root, { withFileTypes: true }); }
90
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
91
+ for (const entry of entries.filter((item) => item.isDirectory() && /^(?:chromium|chromium_headless_shell)-\d+$/.test(item.name)).sort((a, b) => b.name.localeCompare(a.name, 'en', { numeric: true })).slice(0, 50)) {
92
+ const directory = join(root, entry.name);
93
+ for (const relative of [
94
+ 'chrome-linux/chrome', 'chrome-linux64/chrome', 'chrome-linux/headless_shell', 'chrome-headless-shell-linux64/chrome-headless-shell',
95
+ 'chrome-mac/Chromium.app/Contents/MacOS/Chromium', 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing', 'chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
96
+ 'chrome-headless-shell-mac-arm64/chrome-headless-shell', 'chrome-headless-shell-mac-x64/chrome-headless-shell',
97
+ 'chrome-win/chrome.exe', 'chrome-win64/chrome.exe', 'chrome-headless-shell-win64/chrome-headless-shell.exe',
98
+ ]) {
99
+ const candidate = join(directory, relative);
100
+ try { await access(candidate, constants.X_OK); executables.push(candidate); } catch {}
101
+ }
102
+ }
103
+ }
104
+ return executables;
105
+ }
106
+
107
+ async function chooseBrowser(chromium, explicit, diagnostics) {
108
+ const candidates = explicit ? [explicit] : [
109
+ chromium.executablePath(), ...await cachedExecutables(chromium),
110
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium',
111
+ join(homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
112
+ ...['PROGRAMFILES', 'PROGRAMFILES(X86)', 'LOCALAPPDATA'].filter((key) => process.env[key]).flatMap((key) => [join(process.env[key], 'Google/Chrome/Application/chrome.exe'), join(process.env[key], 'Chromium/Application/chrome.exe')]),
113
+ ...(process.env.PATH ?? '').split(delimiter).flatMap((directory) => ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome'].map((name) => join(directory, name))),
114
+ ];
115
+ for (const candidate of new Set(candidates)) {
116
+ let browser;
117
+ try {
118
+ await access(candidate, constants.X_OK);
119
+ const executable = await realpath(candidate);
120
+ const { stdout, stderr } = await execute(executable, ['--version'], { timeout: 5000, maxBuffer: 65536 });
121
+ const version = `${stdout} ${stderr}`.trim();
122
+ assert(/Chrom(?:e|ium)/i.test(version) && Number(/(\d+)\./.exec(version)?.[1]) >= 152, `Incompatible version ${version}. Chrome or Chromium 152+ is required`);
123
+ browser = await chromium.launch({ executablePath: executable, headless: true, chromiumSandbox: true, timeout: 15000 });
124
+ const page = await browser.newPage();
125
+ const userAgent = await page.evaluate(() => navigator.userAgent);
126
+ assert(compatible(userAgent), `Unsupported launched user agent ${userAgent}`);
127
+ await page.close();
128
+ return { browser, identity: { executable, selectedPath: candidate, version: browser.version(), probeVersion: version, userAgent } };
129
+ } catch (error) {
130
+ await browser?.close();
131
+ diagnostics.push({ executable: candidate, reason: message(error) });
132
+ }
133
+ }
134
+ throw new Error('No compatible browser could launch. Use --browser /absolute/path/to/an/existing/Chrome-or-Chromium-152+ executable. Check browserDiagnostics in report.json. No software was installed and browser sandboxing was not disabled.');
135
+ }
136
+
137
+ async function poll(read, expected, timeout) {
138
+ const deadline = Date.now() + timeout;
139
+ let actual;
140
+ do {
141
+ actual = await read();
142
+ try { assert.deepEqual(actual, expected); return; } catch {}
143
+ await new Promise((done) => setTimeout(done, 50));
144
+ } while (Date.now() < deadline);
145
+ assert.deepEqual(actual, expected);
146
+ }
147
+
148
+ async function expectation(frame, step, timeout) {
149
+ const locator = frame.locator(step.selector);
150
+ const actions = {
151
+ click: () => locator.click(), press: () => locator.press(step.key), fill: () => locator.fill(step.value),
152
+ check: () => locator.setChecked(step.checked), select: () => locator.selectOption(step.values),
153
+ };
154
+ if (Object.hasOwn(actions, step.kind)) { await actions[step.kind](); return; }
155
+ if (step.kind === 'count') return poll(() => locator.count(), step.count, timeout);
156
+ if (step.kind === 'visibleCount') return poll(async () => {
157
+ const items = await locator.all();
158
+ const visible = await Promise.all(items.map((item) => item.isVisible()));
159
+ return visible.filter(Boolean).length;
160
+ }, step.count, timeout);
161
+ if (step.kind === 'visible') return poll(async () => {
162
+ const count = await locator.count();
163
+ return { count, visible: count === 1 ? await locator.isVisible() : null };
164
+ }, { count: 1, visible: step.visible }, timeout);
165
+ if (step.kind === 'text') return poll(() => locator.innerText(), step.text, timeout);
166
+ return poll(() => locator.getAttribute(step.name), step.value, timeout);
167
+ }
168
+
169
+ async function dimensions(frame) {
170
+ return frame.evaluate(() => ({ viewportWidth: innerWidth, documentWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0), documentHeight: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0), viewportHeight: innerHeight }));
171
+ }
172
+
173
+ async function capture(page, frame, result, directory, phase) {
174
+ const size = await dimensions(frame);
175
+ const maximum = Math.max(0, size.documentHeight - size.viewportHeight);
176
+ const offsets = [0];
177
+ while (offsets.at(-1) < maximum && offsets.length < 12) offsets.push(Math.min(maximum, offsets.at(-1) + size.viewportHeight));
178
+ const actualOffsets = [];
179
+ for (const offset of offsets) {
180
+ const actual = await frame.evaluate((top) => { scrollTo({ top, left: 0, behavior: 'instant' }); return scrollY; }, offset);
181
+ actualOffsets.push(actual);
182
+ await page.waitForTimeout(50);
183
+ const path = `${result.variant.width}-${result.variant.theme}-${phase}-${offset}.png`;
184
+ await page.screenshot({ path: join(directory, path), timeout: 5000 });
185
+ result.screenshots.push({ path, phase, scrollY: actual, width: result.variant.width, height: result.variant.height });
186
+ }
187
+ const finalSize = await dimensions(frame);
188
+ const capturedThrough = Math.min(finalSize.documentHeight, Math.max(...actualOffsets) + size.viewportHeight);
189
+ const reachedOffsets = actualOffsets.every((actual, index) => Math.abs(actual - offsets[index]) <= 1);
190
+ result.captureCoverage.push({ phase, documentHeight: finalSize.documentHeight, capturedThrough, truncated: !reachedOffsets || capturedThrough < finalSize.documentHeight });
191
+ await frame.evaluate(() => scrollTo({ top: 0, left: 0, behavior: 'instant' }));
192
+ }
193
+
194
+ async function runVariant(browser, origin, config, variant, directory) {
195
+ const deadline = { timeoutMs: Math.min(60000, Math.max(10000, config.timeoutMs * (config.expectations.length + 1))), exceeded: false };
196
+ const result = { variant, status: 'passed', deadline, checks: [], screenshots: [], captureCoverage: [], consoleErrors: [], runtimeErrors: [], externalAttempts: [], errors: [] };
197
+ const context = await browser.newContext({ viewport: { width: variant.width, height: variant.height }, colorScheme: variant.theme, serviceWorkers: 'block' });
198
+ context.setDefaultTimeout(config.timeoutMs);
199
+ let closing;
200
+ const close = () => closing ??= context.close().catch((error) => { result.errors.push(`Browser context cleanup failed. ${message(error)}`); });
201
+ const watchdog = setTimeout(() => {
202
+ deadline.exceeded = true;
203
+ result.errors.push(`Variant deadline exceeded after ${deadline.timeoutMs} ms. Remaining checks, CSP observations, and captures are incomplete.`);
204
+ void close();
205
+ }, deadline.timeoutMs);
206
+ let frame;
207
+ const overflow = async (phase) => {
208
+ const size = await dimensions(frame);
209
+ const passed = size.documentWidth <= size.viewportWidth + 1;
210
+ result.checks.push({ kind: 'overflow', phase, status: passed ? 'passed' : 'failed', ...size });
211
+ };
212
+ try {
213
+ const page = await context.newPage();
214
+ page.on('console', (entry) => { if (entry.type() === 'error') result.consoleErrors.push(entry.text()); });
215
+ page.on('pageerror', (error) => result.runtimeErrors.push(message(error)));
216
+ await context.route('**/*', async (route) => {
217
+ const request = route.request();
218
+ if ([`${origin}/`, `${origin}/content`].includes(request.url()) && request.method() === 'GET' && request.resourceType() === 'document') await route.continue();
219
+ else { result.externalAttempts.push({ source: 'request', url: request.url(), resourceType: request.resourceType() }); await route.abort('blockedbyclient'); }
220
+ });
221
+ await context.addInitScript(() => {
222
+ globalThis.__artifactsLocalViolations = [];
223
+ addEventListener('securitypolicyviolation', (event) => globalThis.__artifactsLocalViolations.push({ source: 'csp', url: event.blockedURI, directive: event.effectiveDirective }));
224
+ });
225
+ const responsePromise = page.waitForResponse((response) => response.url() === `${origin}/content`);
226
+ const [, response] = await Promise.all([page.goto(`${origin}/`, { waitUntil: 'load', timeout: 10000 }), responsePromise]);
227
+ const headers = response.headers();
228
+ result.sandbox = { csp: headers['content-security-policy'], connectionAllowlist: headers['connection-allowlist'], dnsPrefetchControl: headers['x-dns-prefetch-control'], iframeSandbox: await page.locator('iframe').getAttribute('sandbox'), contentSha256: hash(await response.body()) };
229
+ assert.equal(result.sandbox.csp, previewPolicy);
230
+ assert.equal(result.sandbox.connectionAllowlist, '()');
231
+ assert.equal(result.sandbox.dnsPrefetchControl, 'off');
232
+ assert.equal(result.sandbox.iframeSandbox, 'allow-scripts');
233
+ frame = page.frames().find((item) => item.url() === `${origin}/content`);
234
+ assert(frame, 'The local sandboxed content frame must load');
235
+ await frame.evaluate(async (timeout) => {
236
+ await Promise.race([document.fonts.ready, new Promise((_, reject) => setTimeout(() => reject(new Error('Fonts readiness timed out')), timeout))]);
237
+ }, config.timeoutMs);
238
+ await page.waitForTimeout(200);
239
+ await overflow('initial');
240
+ await capture(page, frame, result, directory, 'initial');
241
+ for (const [index, step] of config.expectations.entries()) {
242
+ if (step.when && ((step.when.width !== undefined && step.when.width !== variant.width) || (step.when.theme !== undefined && step.when.theme !== variant.theme))) continue;
243
+ try {
244
+ await expectation(frame, step, config.timeoutMs);
245
+ result.checks.push({ kind: step.kind, step: index, status: 'passed' });
246
+ } catch (error) { result.checks.push({ kind: step.kind, step: index, status: 'failed', message: message(error) }); break; }
247
+ }
248
+ assert.equal(page.url(), `${origin}/`, 'The document must not navigate the wrapper');
249
+ assert.equal(frame.url(), `${origin}/content`, 'The document must not replace its content frame');
250
+ await page.waitForTimeout(200);
251
+ await overflow('final');
252
+ await capture(page, frame, result, directory, 'final');
253
+ } catch (error) { result.errors.push(message(error)); }
254
+ finally {
255
+ if (!deadline.exceeded && frame && !frame.isDetached()) {
256
+ try { result.externalAttempts.push(...await frame.evaluate(() => globalThis.__artifactsLocalViolations ?? [])); }
257
+ catch (error) { result.errors.push(`CSP evidence unavailable. ${message(error)}`); }
258
+ }
259
+ await close();
260
+ clearTimeout(watchdog);
261
+ }
262
+ if (result.checks.some((item) => item.status === 'failed') || result.errors.length || result.consoleErrors.length || result.runtimeErrors.length || result.externalAttempts.length) result.status = 'failed';
263
+ return result;
264
+ }
265
+
266
+ async function main() {
267
+ const report = {
268
+ schemaVersion: 1, evidenceKind: 'renderizado local', authenticatedPublication: 'not-verified', status: 'blocked',
269
+ visualReview: { status: 'required', checks: ['Content fidelity and reading order', 'Composition, alignment, legibility, and focus visibility at both widths and themes', 'Inspect screenshot tiles and any truncated capture coverage'] },
270
+ input: null, browser: null, browserDiagnostics: [],
271
+ sandbox: { csp: previewPolicy, iframeSandbox: 'allow-scripts', connectionAllowlist: '()', dnsPrefetchControl: 'off', referrerPolicy: 'no-referrer', chromiumSandbox: true },
272
+ observation: 'Each fresh variant waits for load and fonts, observes 200 ms before and after its scenario, and captures bounded viewport tiles. Delayed behavior beyond this run requires additional review.',
273
+ variants: [], errors: [],
274
+ };
275
+ let directory;
276
+ let server;
277
+ let browser;
278
+ let config;
279
+ try {
280
+ config = invocation(process.argv.slice(2));
281
+ for (const input of [config.html, config.expectations].filter(Boolean)) assert(config.evidence !== input && !config.evidence.startsWith(`${input}${sep}`), 'Evidence destination must not overwrite an input file');
282
+ await mkdir(config.evidence, { recursive: true });
283
+ directory = await mkdtemp(join(config.evidence, 'local-'));
284
+ report.evidenceDirectory = directory;
285
+ const bytes = await readFile(config.html);
286
+ report.input = { path: await realpath(config.html), sha256: hash(bytes), bytes: bytes.length };
287
+ const checks = config.expectations ? parseExpectations(JSON.parse(await readFile(config.expectations, 'utf8'))) : { expectations: [], timeoutMs: 3000 };
288
+ const content = `<!doctype html><html lang="es"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${escape(framePolicy)}"><meta name="referrer" content="no-referrer"></head><body>${bytes.toString('utf8')}</body></html>`;
289
+ report.sandbox.contentSha256 = hash(content);
290
+ const chosen = await chooseBrowser(playwright(), config.browser, report.browserDiagnostics);
291
+ browser = chosen.browser;
292
+ report.browser = chosen.identity;
293
+ server = createServer((request, response) => {
294
+ if (request.method !== 'GET' || !['/', '/content'].includes(request.url)) { response.writeHead(404).end(); return; }
295
+ response.setHeader('content-type', 'text/html; charset=utf-8');
296
+ response.setHeader('cache-control', 'no-store');
297
+ response.setHeader('x-content-type-options', 'nosniff');
298
+ response.setHeader('referrer-policy', 'no-referrer');
299
+ response.setHeader('x-dns-prefetch-control', 'off');
300
+ if (request.url === '/content') response.setHeader('connection-allowlist', '()');
301
+ response.setHeader('content-security-policy', request.url === '/content' ? previewPolicy : "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; base-uri 'none'; form-action 'none'");
302
+ response.end(request.url === '/content' ? content : '<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><style>html,body{margin:0;width:100%;height:100%;overflow:hidden}iframe{display:block;border:0;width:100%;height:100%}</style><iframe title="Local artifact" sandbox="allow-scripts" referrerpolicy="no-referrer" src="/content"></iframe>');
303
+ });
304
+ server.listen(0, '127.0.0.1');
305
+ await once(server, 'listening');
306
+ const origin = `http://127.0.0.1:${server.address().port}`;
307
+ for (const variant of variants) report.variants.push(await runVariant(browser, origin, checks, variant, directory));
308
+ report.status = report.variants.every((item) => item.status === 'passed') ? 'passed' : 'failed';
309
+ } catch (error) { report.errors.push(message(error)); }
310
+ finally {
311
+ try { await browser?.close(); } catch (error) { report.errors.push(`Browser cleanup failed. ${message(error)}`); }
312
+ if (server) {
313
+ try { await new Promise((done, reject) => server.close((error) => error ? reject(error) : done())); }
314
+ catch (error) { report.errors.push(`Local server cleanup failed. ${message(error)}`); }
315
+ }
316
+ if (report.errors.length && report.status === 'passed') report.status = 'failed';
317
+ if (report.input) {
318
+ try {
319
+ report.input.sha256After = hash(await readFile(config.html));
320
+ assert.equal(report.input.sha256After, report.input.sha256, 'Input HTML changed during verification');
321
+ } catch (error) { report.status = 'failed'; report.errors.push(message(error)); }
322
+ }
323
+ if (directory) {
324
+ await writeFile(join(directory, 'report.json'), `${JSON.stringify(report, null, 2)}\n`);
325
+ const links = report.variants.flatMap((item) => item.screenshots.map((capture) => `- [${capture.path}](${capture.path}). Frame scroll offset ${capture.scrollY} px.`));
326
+ await writeFile(join(directory, 'index.md'), `# Local rendering evidence\n\nEvidence kind is renderizado local. Authenticated publication is not verified.\n\nAutomatic checks ${report.status}. Visual review remains required.\n\n${links.join('\n')}\n`);
327
+ }
328
+ console.log(JSON.stringify(report));
329
+ process.exitCode = report.status === 'passed' ? 0 : report.status === 'failed' ? 1 : 2;
330
+ }
331
+ }
332
+
333
+ await main();
@@ -16,6 +16,7 @@ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
16
16
  text: "text";
17
17
  html: "html";
18
18
  markdown: "markdown";
19
+ design: "design";
19
20
  }>;
20
21
  text: z.ZodString;
21
22
  }, z.core.$strict>;
@@ -55,6 +56,7 @@ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
55
56
  text: "text";
56
57
  html: "html";
57
58
  markdown: "markdown";
59
+ design: "design";
58
60
  }>;
59
61
  text: z.ZodString;
60
62
  }, z.core.$strict>;