@keboola/validate-ui 0.5.3 → 0.6.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/AGENTS.md CHANGED
@@ -8,6 +8,7 @@ rendered result. Reach for it after generating/scaffolding a module, before a PR
8
8
  - **Five axes** live one-per-file in `src/axes/` (`runtime-health`, `a11y`, `visual-brand`, `brief-conformance`, `compare`), each implementing the `Axis` contract in `src/types.ts`. Add an axis: implement `{ name, run }`, register it in `src/axes/index.ts`.
9
9
  - **Contract boundary:** everything an axis needs is on `AxisContext` (`artifact`, live `page`, `brief`, `baseline`, `captureUnderBrand`, `comparison`, `compareConfig`). Don't reach outside it.
10
10
  - **Compare (old-vs-new)** is semantic, not a value multiset — `src/compare/`. Both sides reduce to a serializable `CompareSnapshot` (regions with key/state/values) via `extractRegions`; the pure `diffSnapshots` localizes every delta to a region, suppresses declared `expectedAbsent` deltas (recorded, not dropped), and reports an empty/error `region-degraded` instead of value loss. Multi-route: `validate-ui compare --new <url> (--old <url> | --old-snapshots <dir>) --routes a,b,c [--config f.json]` — `dedupeSharedChrome` collapses a delta seen on ≥2 routes into one finding so shared chrome isn't multiplied. The `compare` axis only activates when `context.comparison` is set, so single-page runs are unaffected.
11
+ - **Viewport is a judgement, not just a size.** `validate({ viewport })` / `--viewport <WIDTHxHEIGHT | desktop | mobile | phone>` renders at that size, and `isPhoneViewport` (< 768px) is what makes horizontal overflow `critical` instead of `moderate`. An axis that cares how narrow the page is reads `context.artifact.viewport` and asks `isPhoneViewport`, never a literal width.
11
12
  - **Do not** depend on `@keboola/e2e-testing` (private) — the screenshot-prep helper is reimplemented in `src/prepare-screenshot.ts`.
12
13
  - **Want one pass/fail number + a gate exit code**, not raw findings? Use `@keboola/ui-gen-bench` — `ui-gen-bench eval --serve <dir> --route <path> --brief "…"` boots via this validator, scores the verdict, and exits 0/1. That's the per-generation gate; this package is the validator underneath it.
13
14
  - **VLM backend** for `brief-conformance` is chosen in `src/axes/vlm-provider.ts`: Keboola LLM proxy (base URL `VALIDATE_UI_LLM_BASE_URL` → `ANTHROPIC_BASE_URL`; token `VALIDATE_UI_LLM_TOKEN` → `KBC_TOKEN` → `ANTHROPIC_API_KEY` — so kai-agent's SDK-native `ANTHROPIC_BASE_URL` + `ANTHROPIC_API_KEY` pair works, preferred) → raw `ANTHROPIC_API_KEY` (no proxy base URL) → skip. Route new VLM calls through `resolveVlmProvider()`, never `new Anthropic()` directly.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @keboola/validate-ui
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Horizontal overflow is blocking below 768px wide
8
+
9
+ `visual-brand` weighs the finding by the width it was measured at: `critical` on a phone-class
10
+ viewport, where the reader has to scroll sideways to finish a sentence, and `moderate` as before at
11
+ desktop widths. Nothing changes for an existing caller — `validate()` still renders at 1280x800
12
+ unless given a `viewport`.
13
+
14
+ `--viewport <WIDTHxHEIGHT | desktop | mobile | phone>` is now on the CLI, and `parseViewport`,
15
+ `PHONE_VIEWPORT`, `PHONE_MAX_WIDTH`, `VIEWPORT_PRESETS` and `isPhoneViewport` are exported.
16
+
3
17
  ## 0.5.3
4
18
 
5
19
  ### Patch Changes
package/README.md CHANGED
@@ -8,8 +8,8 @@ human looks:
8
8
  - **runtime-health** — white-screens, console/page errors, failed requests
9
9
  - **a11y** — axe-core violations mapped to WCAG
10
10
  - **visual-brand** — baseline diff + the alt-brand fitness function (chrome must
11
- re-skin under an alternate brand) + layout overflow (asserting categorical
12
- colors stay fixed is future work)
11
+ re-skin under an alternate brand) + layout overflow, blocking below 768px wide
12
+ (asserting categorical colors stay fixed is future work)
13
13
  - **brief-conformance** — a VLM judge on "did it build what the brief asked,
14
14
  and are the empty/loading/error states present?"
15
15
 
@@ -33,10 +33,19 @@ validate-ui --url http://localhost:5173 --brief "Users list with empty state"
33
33
 
34
34
  # Serve a built SPA dir and validate a route
35
35
  validate-ui --serve apps/boilerplate/dist --route /tokens --json
36
+
37
+ # Judge the page at a phone size instead of the 1280x800 default
38
+ validate-ui --url http://localhost:5173 --viewport 375x812
36
39
  ```
37
40
 
38
41
  Exit code is `0` when every axis passes, `1` otherwise.
39
42
 
43
+ `--viewport` takes `WIDTHxHEIGHT` or one of `desktop` | `mobile` | `phone`.
44
+ Below 768px wide a horizontal layout overflow is a **critical** finding rather
45
+ than a moderate one: the reader has to scroll sideways to finish a sentence, so
46
+ the screen is broken and the verdict fails. Omitting the flag renders at
47
+ 1280x800 and is unchanged.
48
+
40
49
  ## Programmatic
41
50
 
42
51
  ```ts
package/dist/cli.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const require_runner = require("./runner-CEdpbEON.cjs");
2
+ const require_runner = require("./runner-Dhb1FmeB.cjs");
3
3
  let node_fs_promises = require("node:fs/promises");
4
4
  let node_path = require("node:path");
5
5
  let node_process = require("node:process");
@@ -8,8 +8,8 @@ node_process = require_runner.__toESM(node_process, 1);
8
8
  const HELP = `@keboola/validate-ui — closed-loop validation for generated Keboola UI
9
9
 
10
10
  Usage:
11
- validate-ui --url <url> [--brief <text>] [--json]
12
- validate-ui --serve <dir> [--route <path>] [--brief <text>] [--json]
11
+ validate-ui --url <url> [--brief <text>] [--viewport <size>] [--json]
12
+ validate-ui --serve <dir> [--route <path>] [--brief <text>] [--viewport <size>] [--json]
13
13
  validate-ui compare --new <baseUrl> (--old <baseUrl> | --old-snapshots <dir>) --routes <a,b,c> [--config <file>] [--json]
14
14
 
15
15
  Options:
@@ -17,6 +17,8 @@ Options:
17
17
  --serve <dir> Serve a built SPA dir (e.g. apps/boilerplate/dist) and validate it
18
18
  --route <path> Route to open when using --serve (default: /)
19
19
  --brief <text> The brief the UI was generated from (feeds brief-conformance)
20
+ --viewport <size> WIDTHxHEIGHT, or desktop | mobile | phone (default: desktop).
21
+ Horizontal overflow is blocking below 768px wide.
20
22
  --json Emit the verdict as JSON
21
23
  --help, -h Show this help
22
24
 
@@ -114,6 +116,8 @@ const main = async () => {
114
116
  }
115
117
  const brief = flagValue(argv, "--brief");
116
118
  const serveDir = flagValue(argv, "--serve");
119
+ const viewportSpec = flagValue(argv, "--viewport");
120
+ const viewport = viewportSpec === void 0 ? void 0 : require_runner.parseViewport(viewportSpec);
117
121
  let url = flagValue(argv, "--url");
118
122
  let server;
119
123
  if (serveDir !== void 0) {
@@ -129,7 +133,8 @@ const main = async () => {
129
133
  try {
130
134
  const verdict = await require_runner.validate({
131
135
  url,
132
- brief
136
+ brief,
137
+ viewport
133
138
  });
134
139
  node_process.default.stdout.write((asJson ? JSON.stringify(verdict, null, 2) : formatText(verdict)) + "\n");
135
140
  node_process.default.exitCode = verdict.pass ? 0 : 1;
package/dist/cli.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.cjs","names":["CompareConfig","readFile","resolve","compareRoutes","process","serveStatic","validate"],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { readFile } from 'node:fs/promises';\nimport { resolve } from 'node:path';\nimport process from 'node:process';\n\nimport { compareRoutes, type CompareRoutesResult } from './compare/runner';\nimport { CompareConfig } from './compare/types';\nimport { validate } from './aggregate';\nimport { serveStatic } from './serve';\nimport type { AggregateVerdict } from './types';\n\nconst HELP = `@keboola/validate-ui — closed-loop validation for generated Keboola UI\n\nUsage:\n validate-ui --url <url> [--brief <text>] [--json]\n validate-ui --serve <dir> [--route <path>] [--brief <text>] [--json]\n validate-ui compare --new <baseUrl> (--old <baseUrl> | --old-snapshots <dir>) --routes <a,b,c> [--config <file>] [--json]\n\nOptions:\n --url <url> Render and validate a running URL\n --serve <dir> Serve a built SPA dir (e.g. apps/boilerplate/dist) and validate it\n --route <path> Route to open when using --serve (default: /)\n --brief <text> The brief the UI was generated from (feeds brief-conformance)\n --json Emit the verdict as JSON\n --help, -h Show this help\n\ncompare options:\n --new <baseUrl> Base URL of the new build\n --old <baseUrl> Base URL of the old build (booted live)\n --old-snapshots <dir> Directory of recorded old snapshots (alternative to --old)\n --routes <a,b,c> Comma-separated routes to compare\n --config <file> JSON compare config (expectedAbsent allowlist + state markers)\n\nExit code is 0 when every axis / route passes, 1 otherwise.\n`;\n\nconst flagValue = (argv: string[], name: string): string | undefined => {\n const index = argv.indexOf(name);\n if (index < 0) return undefined;\n const value = argv[index + 1];\n // A missing value, or the next token being another flag, means no value was\n // given (e.g. `--url --json`) — reject it so callers fail fast.\n return value === undefined || value.startsWith('-') ? undefined : value;\n};\n\nconst formatText = (verdict: AggregateVerdict): string => {\n const lines = [`validate-ui ${verdict.pass ? 'PASS' : 'FAIL'} — ${verdict.url}`];\n for (const axis of verdict.verdicts) {\n lines.push(` ${axis.pass ? '✓' : '✗'} ${axis.axis}`);\n for (const finding of axis.findings) {\n lines.push(` [${finding.severity}] ${finding.message}`);\n }\n }\n return lines.join('\\n');\n};\n\nconst formatCompareFinding = (finding: CompareRoutesResult['sharedChrome'][number]): string => {\n const tag = finding.suppressed !== undefined ? ' (suppressed)' : '';\n const detail = finding.detail !== undefined ? ` — ${finding.detail}` : '';\n return ` [${finding.severity}] ${finding.message}${detail}${tag}`;\n};\n\nconst formatCompare = (result: CompareRoutesResult): string => {\n const lines = [`validate-ui compare ${result.pass ? 'PASS' : 'FAIL'}`];\n if (result.sharedChrome.length > 0) {\n lines.push(' shared across routes:');\n for (const finding of result.sharedChrome) lines.push(formatCompareFinding(finding));\n }\n for (const route of result.routes) {\n lines.push(` ${route.pass ? '✓' : '✗'} ${route.route}`);\n for (const finding of route.findings) lines.push(formatCompareFinding(finding));\n }\n return lines.join('\\n');\n};\n\nconst loadCompareConfig = async (path: string | undefined): Promise<CompareConfig | undefined> => {\n if (path === undefined) return undefined;\n return CompareConfig.parse(JSON.parse(await readFile(resolve(path), 'utf8')));\n};\n\nconst runCompare = async (argv: string[], asJson: boolean): Promise<void> => {\n const newBaseUrl = flagValue(argv, '--new');\n const oldBaseUrl = flagValue(argv, '--old');\n const oldSnapshotDir = flagValue(argv, '--old-snapshots');\n const routes = flagValue(argv, '--routes')\n ?.split(',')\n .map((route) => route.trim())\n .filter((route) => route !== '');\n\n if (newBaseUrl === undefined || routes === undefined || routes.length === 0) {\n process.stderr.write('validate-ui compare: --new and --routes are required\\n\\n' + HELP);\n process.exitCode = 1;\n return;\n }\n if (oldBaseUrl !== undefined && oldSnapshotDir !== undefined) {\n process.stderr.write(\n 'validate-ui compare: pass only one of --old or --old-snapshots\\n\\n' + HELP,\n );\n process.exitCode = 1;\n return;\n }\n\n const config = await loadCompareConfig(flagValue(argv, '--config'));\n const base = { newBaseUrl, routes, config };\n // Branch so the XOR-typed options narrow without an assertion.\n let result: CompareRoutesResult;\n if (oldBaseUrl !== undefined) {\n result = await compareRoutes({ ...base, oldBaseUrl });\n } else if (oldSnapshotDir !== undefined) {\n result = await compareRoutes({ ...base, oldSnapshotDir });\n } else {\n process.stderr.write('validate-ui compare: provide --old or --old-snapshots\\n\\n' + HELP);\n process.exitCode = 1;\n return;\n }\n process.stdout.write((asJson ? JSON.stringify(result, null, 2) : formatCompare(result)) + '\\n');\n process.exitCode = result.pass ? 0 : 1;\n};\n\nconst main = async (): Promise<void> => {\n const argv = process.argv.slice(2);\n if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) {\n process.stdout.write(HELP);\n return;\n }\n\n const asJson = argv.includes('--json');\n if (argv[0] === 'compare') {\n await runCompare(argv.slice(1), asJson);\n return;\n }\n\n const brief = flagValue(argv, '--brief');\n const serveDir = flagValue(argv, '--serve');\n\n let url = flagValue(argv, '--url');\n let server: Awaited<ReturnType<typeof serveStatic>> | undefined;\n if (serveDir !== undefined) {\n server = await serveStatic(resolve(serveDir));\n const route = flagValue(argv, '--route') ?? '/';\n url = `${server.url}${route.startsWith('/') ? route : `/${route}`}`;\n }\n\n if (url === undefined) {\n process.stderr.write('validate-ui: one of --url or --serve is required\\n\\n' + HELP);\n process.exitCode = 1;\n return;\n }\n\n try {\n const verdict = await validate({ url, brief });\n process.stdout.write((asJson ? JSON.stringify(verdict, null, 2) : formatText(verdict)) + '\\n');\n process.exitCode = verdict.pass ? 0 : 1;\n } finally {\n await server?.close();\n }\n};\n\nmain().catch((error: unknown) => {\n process.stderr.write(`validate-ui: ${error instanceof Error ? error.message : String(error)}\\n`);\n process.exitCode = 1;\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.cjs","names":["CompareConfig","readFile","resolve","compareRoutes","process","parseViewport","serveStatic","validate"],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { readFile } from 'node:fs/promises';\nimport { resolve } from 'node:path';\nimport process from 'node:process';\n\nimport { compareRoutes, type CompareRoutesResult } from './compare/runner';\nimport { CompareConfig } from './compare/types';\nimport { validate } from './aggregate';\nimport { serveStatic } from './serve';\nimport type { AggregateVerdict } from './types';\nimport { parseViewport } from './viewport';\n\nconst HELP = `@keboola/validate-ui — closed-loop validation for generated Keboola UI\n\nUsage:\n validate-ui --url <url> [--brief <text>] [--viewport <size>] [--json]\n validate-ui --serve <dir> [--route <path>] [--brief <text>] [--viewport <size>] [--json]\n validate-ui compare --new <baseUrl> (--old <baseUrl> | --old-snapshots <dir>) --routes <a,b,c> [--config <file>] [--json]\n\nOptions:\n --url <url> Render and validate a running URL\n --serve <dir> Serve a built SPA dir (e.g. apps/boilerplate/dist) and validate it\n --route <path> Route to open when using --serve (default: /)\n --brief <text> The brief the UI was generated from (feeds brief-conformance)\n --viewport <size> WIDTHxHEIGHT, or desktop | mobile | phone (default: desktop).\n Horizontal overflow is blocking below 768px wide.\n --json Emit the verdict as JSON\n --help, -h Show this help\n\ncompare options:\n --new <baseUrl> Base URL of the new build\n --old <baseUrl> Base URL of the old build (booted live)\n --old-snapshots <dir> Directory of recorded old snapshots (alternative to --old)\n --routes <a,b,c> Comma-separated routes to compare\n --config <file> JSON compare config (expectedAbsent allowlist + state markers)\n\nExit code is 0 when every axis / route passes, 1 otherwise.\n`;\n\nconst flagValue = (argv: string[], name: string): string | undefined => {\n const index = argv.indexOf(name);\n if (index < 0) return undefined;\n const value = argv[index + 1];\n // A missing value, or the next token being another flag, means no value was\n // given (e.g. `--url --json`) — reject it so callers fail fast.\n return value === undefined || value.startsWith('-') ? undefined : value;\n};\n\nconst formatText = (verdict: AggregateVerdict): string => {\n const lines = [`validate-ui ${verdict.pass ? 'PASS' : 'FAIL'} — ${verdict.url}`];\n for (const axis of verdict.verdicts) {\n lines.push(` ${axis.pass ? '✓' : '✗'} ${axis.axis}`);\n for (const finding of axis.findings) {\n lines.push(` [${finding.severity}] ${finding.message}`);\n }\n }\n return lines.join('\\n');\n};\n\nconst formatCompareFinding = (finding: CompareRoutesResult['sharedChrome'][number]): string => {\n const tag = finding.suppressed !== undefined ? ' (suppressed)' : '';\n const detail = finding.detail !== undefined ? ` — ${finding.detail}` : '';\n return ` [${finding.severity}] ${finding.message}${detail}${tag}`;\n};\n\nconst formatCompare = (result: CompareRoutesResult): string => {\n const lines = [`validate-ui compare ${result.pass ? 'PASS' : 'FAIL'}`];\n if (result.sharedChrome.length > 0) {\n lines.push(' shared across routes:');\n for (const finding of result.sharedChrome) lines.push(formatCompareFinding(finding));\n }\n for (const route of result.routes) {\n lines.push(` ${route.pass ? '✓' : '✗'} ${route.route}`);\n for (const finding of route.findings) lines.push(formatCompareFinding(finding));\n }\n return lines.join('\\n');\n};\n\nconst loadCompareConfig = async (path: string | undefined): Promise<CompareConfig | undefined> => {\n if (path === undefined) return undefined;\n return CompareConfig.parse(JSON.parse(await readFile(resolve(path), 'utf8')));\n};\n\nconst runCompare = async (argv: string[], asJson: boolean): Promise<void> => {\n const newBaseUrl = flagValue(argv, '--new');\n const oldBaseUrl = flagValue(argv, '--old');\n const oldSnapshotDir = flagValue(argv, '--old-snapshots');\n const routes = flagValue(argv, '--routes')\n ?.split(',')\n .map((route) => route.trim())\n .filter((route) => route !== '');\n\n if (newBaseUrl === undefined || routes === undefined || routes.length === 0) {\n process.stderr.write('validate-ui compare: --new and --routes are required\\n\\n' + HELP);\n process.exitCode = 1;\n return;\n }\n if (oldBaseUrl !== undefined && oldSnapshotDir !== undefined) {\n process.stderr.write(\n 'validate-ui compare: pass only one of --old or --old-snapshots\\n\\n' + HELP,\n );\n process.exitCode = 1;\n return;\n }\n\n const config = await loadCompareConfig(flagValue(argv, '--config'));\n const base = { newBaseUrl, routes, config };\n // Branch so the XOR-typed options narrow without an assertion.\n let result: CompareRoutesResult;\n if (oldBaseUrl !== undefined) {\n result = await compareRoutes({ ...base, oldBaseUrl });\n } else if (oldSnapshotDir !== undefined) {\n result = await compareRoutes({ ...base, oldSnapshotDir });\n } else {\n process.stderr.write('validate-ui compare: provide --old or --old-snapshots\\n\\n' + HELP);\n process.exitCode = 1;\n return;\n }\n process.stdout.write((asJson ? JSON.stringify(result, null, 2) : formatCompare(result)) + '\\n');\n process.exitCode = result.pass ? 0 : 1;\n};\n\nconst main = async (): Promise<void> => {\n const argv = process.argv.slice(2);\n if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) {\n process.stdout.write(HELP);\n return;\n }\n\n const asJson = argv.includes('--json');\n if (argv[0] === 'compare') {\n await runCompare(argv.slice(1), asJson);\n return;\n }\n\n const brief = flagValue(argv, '--brief');\n const serveDir = flagValue(argv, '--serve');\n const viewportSpec = flagValue(argv, '--viewport');\n const viewport = viewportSpec === undefined ? undefined : parseViewport(viewportSpec);\n\n let url = flagValue(argv, '--url');\n let server: Awaited<ReturnType<typeof serveStatic>> | undefined;\n if (serveDir !== undefined) {\n server = await serveStatic(resolve(serveDir));\n const route = flagValue(argv, '--route') ?? '/';\n url = `${server.url}${route.startsWith('/') ? route : `/${route}`}`;\n }\n\n if (url === undefined) {\n process.stderr.write('validate-ui: one of --url or --serve is required\\n\\n' + HELP);\n process.exitCode = 1;\n return;\n }\n\n try {\n const verdict = await validate({ url, brief, viewport });\n process.stdout.write((asJson ? JSON.stringify(verdict, null, 2) : formatText(verdict)) + '\\n');\n process.exitCode = verdict.pass ? 0 : 1;\n } finally {\n await server?.close();\n }\n};\n\nmain().catch((error: unknown) => {\n process.stderr.write(`validate-ui: ${error instanceof Error ? error.message : String(error)}\\n`);\n process.exitCode = 1;\n});\n"],"mappings":"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"}
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { m as CompareConfig, o as validate, t as compareRoutes, w as serveStatic } from "./runner-CLpaiCIG.js";
2
+ import { k as parseViewport, m as CompareConfig, o as validate, t as compareRoutes, w as serveStatic } from "./runner-D5dD5L7c.js";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { resolve } from "node:path";
5
5
  import process from "node:process";
@@ -7,8 +7,8 @@ import process from "node:process";
7
7
  const HELP = `@keboola/validate-ui — closed-loop validation for generated Keboola UI
8
8
 
9
9
  Usage:
10
- validate-ui --url <url> [--brief <text>] [--json]
11
- validate-ui --serve <dir> [--route <path>] [--brief <text>] [--json]
10
+ validate-ui --url <url> [--brief <text>] [--viewport <size>] [--json]
11
+ validate-ui --serve <dir> [--route <path>] [--brief <text>] [--viewport <size>] [--json]
12
12
  validate-ui compare --new <baseUrl> (--old <baseUrl> | --old-snapshots <dir>) --routes <a,b,c> [--config <file>] [--json]
13
13
 
14
14
  Options:
@@ -16,6 +16,8 @@ Options:
16
16
  --serve <dir> Serve a built SPA dir (e.g. apps/boilerplate/dist) and validate it
17
17
  --route <path> Route to open when using --serve (default: /)
18
18
  --brief <text> The brief the UI was generated from (feeds brief-conformance)
19
+ --viewport <size> WIDTHxHEIGHT, or desktop | mobile | phone (default: desktop).
20
+ Horizontal overflow is blocking below 768px wide.
19
21
  --json Emit the verdict as JSON
20
22
  --help, -h Show this help
21
23
 
@@ -113,6 +115,8 @@ const main = async () => {
113
115
  }
114
116
  const brief = flagValue(argv, "--brief");
115
117
  const serveDir = flagValue(argv, "--serve");
118
+ const viewportSpec = flagValue(argv, "--viewport");
119
+ const viewport = viewportSpec === void 0 ? void 0 : parseViewport(viewportSpec);
116
120
  let url = flagValue(argv, "--url");
117
121
  let server;
118
122
  if (serveDir !== void 0) {
@@ -128,7 +132,8 @@ const main = async () => {
128
132
  try {
129
133
  const verdict = await validate({
130
134
  url,
131
- brief
135
+ brief,
136
+ viewport
132
137
  });
133
138
  process.stdout.write((asJson ? JSON.stringify(verdict, null, 2) : formatText(verdict)) + "\n");
134
139
  process.exitCode = verdict.pass ? 0 : 1;
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { readFile } from 'node:fs/promises';\nimport { resolve } from 'node:path';\nimport process from 'node:process';\n\nimport { compareRoutes, type CompareRoutesResult } from './compare/runner';\nimport { CompareConfig } from './compare/types';\nimport { validate } from './aggregate';\nimport { serveStatic } from './serve';\nimport type { AggregateVerdict } from './types';\n\nconst HELP = `@keboola/validate-ui — closed-loop validation for generated Keboola UI\n\nUsage:\n validate-ui --url <url> [--brief <text>] [--json]\n validate-ui --serve <dir> [--route <path>] [--brief <text>] [--json]\n validate-ui compare --new <baseUrl> (--old <baseUrl> | --old-snapshots <dir>) --routes <a,b,c> [--config <file>] [--json]\n\nOptions:\n --url <url> Render and validate a running URL\n --serve <dir> Serve a built SPA dir (e.g. apps/boilerplate/dist) and validate it\n --route <path> Route to open when using --serve (default: /)\n --brief <text> The brief the UI was generated from (feeds brief-conformance)\n --json Emit the verdict as JSON\n --help, -h Show this help\n\ncompare options:\n --new <baseUrl> Base URL of the new build\n --old <baseUrl> Base URL of the old build (booted live)\n --old-snapshots <dir> Directory of recorded old snapshots (alternative to --old)\n --routes <a,b,c> Comma-separated routes to compare\n --config <file> JSON compare config (expectedAbsent allowlist + state markers)\n\nExit code is 0 when every axis / route passes, 1 otherwise.\n`;\n\nconst flagValue = (argv: string[], name: string): string | undefined => {\n const index = argv.indexOf(name);\n if (index < 0) return undefined;\n const value = argv[index + 1];\n // A missing value, or the next token being another flag, means no value was\n // given (e.g. `--url --json`) — reject it so callers fail fast.\n return value === undefined || value.startsWith('-') ? undefined : value;\n};\n\nconst formatText = (verdict: AggregateVerdict): string => {\n const lines = [`validate-ui ${verdict.pass ? 'PASS' : 'FAIL'} — ${verdict.url}`];\n for (const axis of verdict.verdicts) {\n lines.push(` ${axis.pass ? '✓' : '✗'} ${axis.axis}`);\n for (const finding of axis.findings) {\n lines.push(` [${finding.severity}] ${finding.message}`);\n }\n }\n return lines.join('\\n');\n};\n\nconst formatCompareFinding = (finding: CompareRoutesResult['sharedChrome'][number]): string => {\n const tag = finding.suppressed !== undefined ? ' (suppressed)' : '';\n const detail = finding.detail !== undefined ? ` — ${finding.detail}` : '';\n return ` [${finding.severity}] ${finding.message}${detail}${tag}`;\n};\n\nconst formatCompare = (result: CompareRoutesResult): string => {\n const lines = [`validate-ui compare ${result.pass ? 'PASS' : 'FAIL'}`];\n if (result.sharedChrome.length > 0) {\n lines.push(' shared across routes:');\n for (const finding of result.sharedChrome) lines.push(formatCompareFinding(finding));\n }\n for (const route of result.routes) {\n lines.push(` ${route.pass ? '✓' : '✗'} ${route.route}`);\n for (const finding of route.findings) lines.push(formatCompareFinding(finding));\n }\n return lines.join('\\n');\n};\n\nconst loadCompareConfig = async (path: string | undefined): Promise<CompareConfig | undefined> => {\n if (path === undefined) return undefined;\n return CompareConfig.parse(JSON.parse(await readFile(resolve(path), 'utf8')));\n};\n\nconst runCompare = async (argv: string[], asJson: boolean): Promise<void> => {\n const newBaseUrl = flagValue(argv, '--new');\n const oldBaseUrl = flagValue(argv, '--old');\n const oldSnapshotDir = flagValue(argv, '--old-snapshots');\n const routes = flagValue(argv, '--routes')\n ?.split(',')\n .map((route) => route.trim())\n .filter((route) => route !== '');\n\n if (newBaseUrl === undefined || routes === undefined || routes.length === 0) {\n process.stderr.write('validate-ui compare: --new and --routes are required\\n\\n' + HELP);\n process.exitCode = 1;\n return;\n }\n if (oldBaseUrl !== undefined && oldSnapshotDir !== undefined) {\n process.stderr.write(\n 'validate-ui compare: pass only one of --old or --old-snapshots\\n\\n' + HELP,\n );\n process.exitCode = 1;\n return;\n }\n\n const config = await loadCompareConfig(flagValue(argv, '--config'));\n const base = { newBaseUrl, routes, config };\n // Branch so the XOR-typed options narrow without an assertion.\n let result: CompareRoutesResult;\n if (oldBaseUrl !== undefined) {\n result = await compareRoutes({ ...base, oldBaseUrl });\n } else if (oldSnapshotDir !== undefined) {\n result = await compareRoutes({ ...base, oldSnapshotDir });\n } else {\n process.stderr.write('validate-ui compare: provide --old or --old-snapshots\\n\\n' + HELP);\n process.exitCode = 1;\n return;\n }\n process.stdout.write((asJson ? JSON.stringify(result, null, 2) : formatCompare(result)) + '\\n');\n process.exitCode = result.pass ? 0 : 1;\n};\n\nconst main = async (): Promise<void> => {\n const argv = process.argv.slice(2);\n if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) {\n process.stdout.write(HELP);\n return;\n }\n\n const asJson = argv.includes('--json');\n if (argv[0] === 'compare') {\n await runCompare(argv.slice(1), asJson);\n return;\n }\n\n const brief = flagValue(argv, '--brief');\n const serveDir = flagValue(argv, '--serve');\n\n let url = flagValue(argv, '--url');\n let server: Awaited<ReturnType<typeof serveStatic>> | undefined;\n if (serveDir !== undefined) {\n server = await serveStatic(resolve(serveDir));\n const route = flagValue(argv, '--route') ?? '/';\n url = `${server.url}${route.startsWith('/') ? route : `/${route}`}`;\n }\n\n if (url === undefined) {\n process.stderr.write('validate-ui: one of --url or --serve is required\\n\\n' + HELP);\n process.exitCode = 1;\n return;\n }\n\n try {\n const verdict = await validate({ url, brief });\n process.stdout.write((asJson ? JSON.stringify(verdict, null, 2) : formatText(verdict)) + '\\n');\n process.exitCode = verdict.pass ? 0 : 1;\n } finally {\n await server?.close();\n }\n};\n\nmain().catch((error: unknown) => {\n process.stderr.write(`validate-ui: ${error instanceof Error ? error.message : String(error)}\\n`);\n process.exitCode = 1;\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { readFile } from 'node:fs/promises';\nimport { resolve } from 'node:path';\nimport process from 'node:process';\n\nimport { compareRoutes, type CompareRoutesResult } from './compare/runner';\nimport { CompareConfig } from './compare/types';\nimport { validate } from './aggregate';\nimport { serveStatic } from './serve';\nimport type { AggregateVerdict } from './types';\nimport { parseViewport } from './viewport';\n\nconst HELP = `@keboola/validate-ui — closed-loop validation for generated Keboola UI\n\nUsage:\n validate-ui --url <url> [--brief <text>] [--viewport <size>] [--json]\n validate-ui --serve <dir> [--route <path>] [--brief <text>] [--viewport <size>] [--json]\n validate-ui compare --new <baseUrl> (--old <baseUrl> | --old-snapshots <dir>) --routes <a,b,c> [--config <file>] [--json]\n\nOptions:\n --url <url> Render and validate a running URL\n --serve <dir> Serve a built SPA dir (e.g. apps/boilerplate/dist) and validate it\n --route <path> Route to open when using --serve (default: /)\n --brief <text> The brief the UI was generated from (feeds brief-conformance)\n --viewport <size> WIDTHxHEIGHT, or desktop | mobile | phone (default: desktop).\n Horizontal overflow is blocking below 768px wide.\n --json Emit the verdict as JSON\n --help, -h Show this help\n\ncompare options:\n --new <baseUrl> Base URL of the new build\n --old <baseUrl> Base URL of the old build (booted live)\n --old-snapshots <dir> Directory of recorded old snapshots (alternative to --old)\n --routes <a,b,c> Comma-separated routes to compare\n --config <file> JSON compare config (expectedAbsent allowlist + state markers)\n\nExit code is 0 when every axis / route passes, 1 otherwise.\n`;\n\nconst flagValue = (argv: string[], name: string): string | undefined => {\n const index = argv.indexOf(name);\n if (index < 0) return undefined;\n const value = argv[index + 1];\n // A missing value, or the next token being another flag, means no value was\n // given (e.g. `--url --json`) — reject it so callers fail fast.\n return value === undefined || value.startsWith('-') ? undefined : value;\n};\n\nconst formatText = (verdict: AggregateVerdict): string => {\n const lines = [`validate-ui ${verdict.pass ? 'PASS' : 'FAIL'} — ${verdict.url}`];\n for (const axis of verdict.verdicts) {\n lines.push(` ${axis.pass ? '✓' : '✗'} ${axis.axis}`);\n for (const finding of axis.findings) {\n lines.push(` [${finding.severity}] ${finding.message}`);\n }\n }\n return lines.join('\\n');\n};\n\nconst formatCompareFinding = (finding: CompareRoutesResult['sharedChrome'][number]): string => {\n const tag = finding.suppressed !== undefined ? ' (suppressed)' : '';\n const detail = finding.detail !== undefined ? ` — ${finding.detail}` : '';\n return ` [${finding.severity}] ${finding.message}${detail}${tag}`;\n};\n\nconst formatCompare = (result: CompareRoutesResult): string => {\n const lines = [`validate-ui compare ${result.pass ? 'PASS' : 'FAIL'}`];\n if (result.sharedChrome.length > 0) {\n lines.push(' shared across routes:');\n for (const finding of result.sharedChrome) lines.push(formatCompareFinding(finding));\n }\n for (const route of result.routes) {\n lines.push(` ${route.pass ? '✓' : '✗'} ${route.route}`);\n for (const finding of route.findings) lines.push(formatCompareFinding(finding));\n }\n return lines.join('\\n');\n};\n\nconst loadCompareConfig = async (path: string | undefined): Promise<CompareConfig | undefined> => {\n if (path === undefined) return undefined;\n return CompareConfig.parse(JSON.parse(await readFile(resolve(path), 'utf8')));\n};\n\nconst runCompare = async (argv: string[], asJson: boolean): Promise<void> => {\n const newBaseUrl = flagValue(argv, '--new');\n const oldBaseUrl = flagValue(argv, '--old');\n const oldSnapshotDir = flagValue(argv, '--old-snapshots');\n const routes = flagValue(argv, '--routes')\n ?.split(',')\n .map((route) => route.trim())\n .filter((route) => route !== '');\n\n if (newBaseUrl === undefined || routes === undefined || routes.length === 0) {\n process.stderr.write('validate-ui compare: --new and --routes are required\\n\\n' + HELP);\n process.exitCode = 1;\n return;\n }\n if (oldBaseUrl !== undefined && oldSnapshotDir !== undefined) {\n process.stderr.write(\n 'validate-ui compare: pass only one of --old or --old-snapshots\\n\\n' + HELP,\n );\n process.exitCode = 1;\n return;\n }\n\n const config = await loadCompareConfig(flagValue(argv, '--config'));\n const base = { newBaseUrl, routes, config };\n // Branch so the XOR-typed options narrow without an assertion.\n let result: CompareRoutesResult;\n if (oldBaseUrl !== undefined) {\n result = await compareRoutes({ ...base, oldBaseUrl });\n } else if (oldSnapshotDir !== undefined) {\n result = await compareRoutes({ ...base, oldSnapshotDir });\n } else {\n process.stderr.write('validate-ui compare: provide --old or --old-snapshots\\n\\n' + HELP);\n process.exitCode = 1;\n return;\n }\n process.stdout.write((asJson ? JSON.stringify(result, null, 2) : formatCompare(result)) + '\\n');\n process.exitCode = result.pass ? 0 : 1;\n};\n\nconst main = async (): Promise<void> => {\n const argv = process.argv.slice(2);\n if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) {\n process.stdout.write(HELP);\n return;\n }\n\n const asJson = argv.includes('--json');\n if (argv[0] === 'compare') {\n await runCompare(argv.slice(1), asJson);\n return;\n }\n\n const brief = flagValue(argv, '--brief');\n const serveDir = flagValue(argv, '--serve');\n const viewportSpec = flagValue(argv, '--viewport');\n const viewport = viewportSpec === undefined ? undefined : parseViewport(viewportSpec);\n\n let url = flagValue(argv, '--url');\n let server: Awaited<ReturnType<typeof serveStatic>> | undefined;\n if (serveDir !== undefined) {\n server = await serveStatic(resolve(serveDir));\n const route = flagValue(argv, '--route') ?? '/';\n url = `${server.url}${route.startsWith('/') ? route : `/${route}`}`;\n }\n\n if (url === undefined) {\n process.stderr.write('validate-ui: one of --url or --serve is required\\n\\n' + HELP);\n process.exitCode = 1;\n return;\n }\n\n try {\n const verdict = await validate({ url, brief, viewport });\n process.stdout.write((asJson ? JSON.stringify(verdict, null, 2) : formatText(verdict)) + '\\n');\n process.exitCode = verdict.pass ? 0 : 1;\n } finally {\n await server?.close();\n }\n};\n\nmain().catch((error: unknown) => {\n process.stderr.write(`validate-ui: ${error instanceof Error ? error.message : String(error)}\\n`);\n process.exitCode = 1;\n});\n"],"mappings":"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"}
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_runner = require("./runner-CEdpbEON.cjs");
2
+ const require_runner = require("./runner-Dhb1FmeB.cjs");
3
3
  //#region src/types.ts
4
4
  const SEVERITIES = [
5
5
  "critical",
@@ -15,9 +15,12 @@ exports.DEFAULT_VIEWPORTS = require_runner.DEFAULT_VIEWPORTS;
15
15
  exports.DESKTOP_VIEWPORT = require_runner.DESKTOP_VIEWPORT;
16
16
  exports.ExpectedAbsentRule = require_runner.ExpectedAbsentRule;
17
17
  exports.MOBILE_VIEWPORT = require_runner.MOBILE_VIEWPORT;
18
+ exports.PHONE_MAX_WIDTH = require_runner.PHONE_MAX_WIDTH;
19
+ exports.PHONE_VIEWPORT = require_runner.PHONE_VIEWPORT;
18
20
  exports.REGION_STATES = require_runner.REGION_STATES;
19
21
  exports.Region = require_runner.Region;
20
22
  exports.SEVERITIES = SEVERITIES;
23
+ exports.VIEWPORT_PRESETS = require_runner.VIEWPORT_PRESETS;
21
24
  exports.accessibilityAxis = require_runner.accessibilityAxis;
22
25
  exports.aggregatePass = require_runner.aggregatePass;
23
26
  exports.briefConformanceAxis = require_runner.briefConformanceAxis;
@@ -30,7 +33,9 @@ exports.compareRoutes = require_runner.compareRoutes;
30
33
  exports.dedupeSharedChrome = require_runner.dedupeSharedChrome;
31
34
  exports.diffSnapshots = require_runner.diffSnapshots;
32
35
  exports.extractRegions = require_runner.extractRegions;
36
+ exports.isPhoneViewport = require_runner.isPhoneViewport;
33
37
  exports.loadSnapshot = require_runner.loadSnapshot;
38
+ exports.parseViewport = require_runner.parseViewport;
34
39
  exports.prepareForScreenshot = require_runner.prepareForScreenshot;
35
40
  exports.routeSnapshotFile = require_runner.routeSnapshotFile;
36
41
  exports.runAxes = require_runner.runAxes;
package/dist/index.d.cts CHANGED
@@ -203,6 +203,43 @@ type CaptureOptions = {
203
203
  */
204
204
  declare const capture: (url: string, options?: CaptureOptions) => Promise<CaptureArtifact[]>;
205
205
  //#endregion
206
+ //#region src/viewport.d.ts
207
+ /**
208
+ * The phone-first baseline every Keboola screen is designed against.
209
+ *
210
+ * - 375×812 is the narrowest width still in meaningful use; a layout that survives it
211
+ * survives every wider phone.
212
+ * - Distinct from {@link MOBILE_VIEWPORT} (390×844), which is one of `capture`'s two
213
+ * default capture sizes rather than a bar anything is held to.
214
+ */
215
+ declare const PHONE_VIEWPORT: Viewport;
216
+ /** Names `--viewport` accepts in place of a `WIDTHxHEIGHT` pair. */
217
+ declare const VIEWPORT_PRESETS: Readonly<Record<string, Viewport>>;
218
+ /**
219
+ * Widths below this count as a phone.
220
+ *
221
+ * - 768 is the tablet breakpoint: at or above it a page has room for a desktop-shaped
222
+ * layout, below it the content has one column to live in.
223
+ * - Drives severity, not layout — see {@link isPhoneViewport}.
224
+ */
225
+ declare const PHONE_MAX_WIDTH = 768;
226
+ /**
227
+ * Whether a viewport is phone-class.
228
+ *
229
+ * - Horizontal overflow here is a broken page, not a cosmetic slip: the reader has to
230
+ * scroll sideways to read a sentence. Axes weigh findings accordingly.
231
+ */
232
+ declare const isPhoneViewport: (viewport: Viewport) => boolean;
233
+ /**
234
+ * Read a `--viewport` value: a preset name, or `WIDTHxHEIGHT` in CSS pixels.
235
+ *
236
+ * - Throws on anything else rather than falling back to a default — a typo'd size would
237
+ * otherwise silently evaluate the viewport the caller was trying to move off.
238
+ * - Zero in either dimension is rejected for the same reason; playwright accepts it and
239
+ * renders nothing.
240
+ */
241
+ declare const parseViewport: (spec: string) => Viewport;
242
+ //#endregion
206
243
  //#region src/prepare-screenshot.d.ts
207
244
  /**
208
245
  * Settle the page before a screenshot: freeze animations first (so parking the
@@ -293,9 +330,10 @@ declare const runtimeHealthAxis: Axis;
293
330
  * Baseline diff of `context.artifact.screenshot` vs `context.baseline`
294
331
  * (pixelmatch + pngjs), plus the alt-brand fitness function via
295
332
  * `context.captureUnderBrand`: the chrome must visibly re-skin under an
296
- * alternate brand. Flags layout overflow. Each check degrades to no finding when
297
- * its input is absent. (Asserting categorical colors — Badge/Alert/ModalIcon —
298
- * stay fixed pixel-wise is future work; see `altBrandFinding`.)
333
+ * alternate brand. Flags layout overflow, blocking at phone widths. Each check
334
+ * degrades to no finding when its input is absent. (Asserting categorical colors
335
+ * — Badge/Alert/ModalIcon — stay fixed pixel-wise is future work; see
336
+ * `altBrandFinding`.)
299
337
  */
300
338
  declare const visualBrandAxis: Axis;
301
339
  //#endregion
@@ -402,5 +440,5 @@ declare const saveSnapshot: (snapshot: CompareSnapshot, path: string) => Promise
402
440
  /** Read and validate a recorded snapshot from disk. */
403
441
  declare const loadSnapshot: (path: string) => Promise<CompareSnapshot>;
404
442
  //#endregion
405
- export { ALL_AXES, type AggregateVerdict, type Axis, type AxisContext, type CaptureArtifact, type CaptureOptions, CompareConfig, type CompareFinding, type CompareRoutesOptions, type CompareRoutesResult, CompareSnapshot, type ConsoleEntry, DEFAULT_VIEWPORTS, DESKTOP_VIEWPORT, type DedupResult, ExpectedAbsentRule, type Finding, MOBILE_VIEWPORT, type NetworkEntry, REGION_STATES, Region, type RegionState, type RouteCompareResult, type RouteFindings, SEVERITIES, type Severity, type StaticServer, type ValidateOptions, type Verdict, type Viewport, accessibilityAxis, aggregatePass, briefConformanceAxis, capture, capturePage, captureSnapshot, compareAxis, comparePass, compareRoutes, dedupeSharedChrome, diffSnapshots, extractRegions, loadSnapshot, prepareForScreenshot, routeSnapshotFile, runAxes, runtimeHealthAxis, saveSnapshot, serveStatic, validate, visualBrandAxis };
443
+ export { ALL_AXES, type AggregateVerdict, type Axis, type AxisContext, type CaptureArtifact, type CaptureOptions, CompareConfig, type CompareFinding, type CompareRoutesOptions, type CompareRoutesResult, CompareSnapshot, type ConsoleEntry, DEFAULT_VIEWPORTS, DESKTOP_VIEWPORT, type DedupResult, ExpectedAbsentRule, type Finding, MOBILE_VIEWPORT, type NetworkEntry, PHONE_MAX_WIDTH, PHONE_VIEWPORT, REGION_STATES, Region, type RegionState, type RouteCompareResult, type RouteFindings, SEVERITIES, type Severity, type StaticServer, VIEWPORT_PRESETS, type ValidateOptions, type Verdict, type Viewport, accessibilityAxis, aggregatePass, briefConformanceAxis, capture, capturePage, captureSnapshot, compareAxis, comparePass, compareRoutes, dedupeSharedChrome, diffSnapshots, extractRegions, isPhoneViewport, loadSnapshot, parseViewport, prepareForScreenshot, routeSnapshotFile, runAxes, runtimeHealthAxis, saveSnapshot, serveStatic, validate, visualBrandAxis };
406
444
  //# sourceMappingURL=index.d.cts.map
package/dist/index.d.ts CHANGED
@@ -203,6 +203,43 @@ type CaptureOptions = {
203
203
  */
204
204
  declare const capture: (url: string, options?: CaptureOptions) => Promise<CaptureArtifact[]>;
205
205
  //#endregion
206
+ //#region src/viewport.d.ts
207
+ /**
208
+ * The phone-first baseline every Keboola screen is designed against.
209
+ *
210
+ * - 375×812 is the narrowest width still in meaningful use; a layout that survives it
211
+ * survives every wider phone.
212
+ * - Distinct from {@link MOBILE_VIEWPORT} (390×844), which is one of `capture`'s two
213
+ * default capture sizes rather than a bar anything is held to.
214
+ */
215
+ declare const PHONE_VIEWPORT: Viewport;
216
+ /** Names `--viewport` accepts in place of a `WIDTHxHEIGHT` pair. */
217
+ declare const VIEWPORT_PRESETS: Readonly<Record<string, Viewport>>;
218
+ /**
219
+ * Widths below this count as a phone.
220
+ *
221
+ * - 768 is the tablet breakpoint: at or above it a page has room for a desktop-shaped
222
+ * layout, below it the content has one column to live in.
223
+ * - Drives severity, not layout — see {@link isPhoneViewport}.
224
+ */
225
+ declare const PHONE_MAX_WIDTH = 768;
226
+ /**
227
+ * Whether a viewport is phone-class.
228
+ *
229
+ * - Horizontal overflow here is a broken page, not a cosmetic slip: the reader has to
230
+ * scroll sideways to read a sentence. Axes weigh findings accordingly.
231
+ */
232
+ declare const isPhoneViewport: (viewport: Viewport) => boolean;
233
+ /**
234
+ * Read a `--viewport` value: a preset name, or `WIDTHxHEIGHT` in CSS pixels.
235
+ *
236
+ * - Throws on anything else rather than falling back to a default — a typo'd size would
237
+ * otherwise silently evaluate the viewport the caller was trying to move off.
238
+ * - Zero in either dimension is rejected for the same reason; playwright accepts it and
239
+ * renders nothing.
240
+ */
241
+ declare const parseViewport: (spec: string) => Viewport;
242
+ //#endregion
206
243
  //#region src/prepare-screenshot.d.ts
207
244
  /**
208
245
  * Settle the page before a screenshot: freeze animations first (so parking the
@@ -293,9 +330,10 @@ declare const runtimeHealthAxis: Axis;
293
330
  * Baseline diff of `context.artifact.screenshot` vs `context.baseline`
294
331
  * (pixelmatch + pngjs), plus the alt-brand fitness function via
295
332
  * `context.captureUnderBrand`: the chrome must visibly re-skin under an
296
- * alternate brand. Flags layout overflow. Each check degrades to no finding when
297
- * its input is absent. (Asserting categorical colors — Badge/Alert/ModalIcon —
298
- * stay fixed pixel-wise is future work; see `altBrandFinding`.)
333
+ * alternate brand. Flags layout overflow, blocking at phone widths. Each check
334
+ * degrades to no finding when its input is absent. (Asserting categorical colors
335
+ * — Badge/Alert/ModalIcon — stay fixed pixel-wise is future work; see
336
+ * `altBrandFinding`.)
299
337
  */
300
338
  declare const visualBrandAxis: Axis;
301
339
  //#endregion
@@ -402,5 +440,5 @@ declare const saveSnapshot: (snapshot: CompareSnapshot, path: string) => Promise
402
440
  /** Read and validate a recorded snapshot from disk. */
403
441
  declare const loadSnapshot: (path: string) => Promise<CompareSnapshot>;
404
442
  //#endregion
405
- export { ALL_AXES, type AggregateVerdict, type Axis, type AxisContext, type CaptureArtifact, type CaptureOptions, CompareConfig, type CompareFinding, type CompareRoutesOptions, type CompareRoutesResult, CompareSnapshot, type ConsoleEntry, DEFAULT_VIEWPORTS, DESKTOP_VIEWPORT, type DedupResult, ExpectedAbsentRule, type Finding, MOBILE_VIEWPORT, type NetworkEntry, REGION_STATES, Region, type RegionState, type RouteCompareResult, type RouteFindings, SEVERITIES, type Severity, type StaticServer, type ValidateOptions, type Verdict, type Viewport, accessibilityAxis, aggregatePass, briefConformanceAxis, capture, capturePage, captureSnapshot, compareAxis, comparePass, compareRoutes, dedupeSharedChrome, diffSnapshots, extractRegions, loadSnapshot, prepareForScreenshot, routeSnapshotFile, runAxes, runtimeHealthAxis, saveSnapshot, serveStatic, validate, visualBrandAxis };
443
+ export { ALL_AXES, type AggregateVerdict, type Axis, type AxisContext, type CaptureArtifact, type CaptureOptions, CompareConfig, type CompareFinding, type CompareRoutesOptions, type CompareRoutesResult, CompareSnapshot, type ConsoleEntry, DEFAULT_VIEWPORTS, DESKTOP_VIEWPORT, type DedupResult, ExpectedAbsentRule, type Finding, MOBILE_VIEWPORT, type NetworkEntry, PHONE_MAX_WIDTH, PHONE_VIEWPORT, REGION_STATES, Region, type RegionState, type RouteCompareResult, type RouteFindings, SEVERITIES, type Severity, type StaticServer, VIEWPORT_PRESETS, type ValidateOptions, type Verdict, type Viewport, accessibilityAxis, aggregatePass, briefConformanceAxis, capture, capturePage, captureSnapshot, compareAxis, comparePass, compareRoutes, dedupeSharedChrome, diffSnapshots, extractRegions, isPhoneViewport, loadSnapshot, parseViewport, prepareForScreenshot, routeSnapshotFile, runAxes, runtimeHealthAxis, saveSnapshot, serveStatic, validate, visualBrandAxis };
406
444
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as prepareForScreenshot, C as accessibilityAxis, D as MOBILE_VIEWPORT, E as DESKTOP_VIEWPORT, O as capture, S as briefConformanceAxis, T as DEFAULT_VIEWPORTS, _ as REGION_STATES, a as runAxes, b as comparePass, c as visualBrandAxis, d as captureSnapshot, f as loadSnapshot, g as ExpectedAbsentRule, h as CompareSnapshot, i as aggregatePass, k as capturePage, l as runtimeHealthAxis, m as CompareConfig, n as routeSnapshotFile, o as validate, p as saveSnapshot, r as dedupeSharedChrome, s as ALL_AXES, t as compareRoutes, u as compareAxis, v as Region, w as serveStatic, x as diffSnapshots, y as extractRegions } from "./runner-CLpaiCIG.js";
1
+ import { A as DEFAULT_VIEWPORTS, C as accessibilityAxis, D as VIEWPORT_PRESETS, E as PHONE_VIEWPORT, F as prepareForScreenshot, M as MOBILE_VIEWPORT, N as capture, O as isPhoneViewport, P as capturePage, S as briefConformanceAxis, T as PHONE_MAX_WIDTH, _ as REGION_STATES, a as runAxes, b as comparePass, c as visualBrandAxis, d as captureSnapshot, f as loadSnapshot, g as ExpectedAbsentRule, h as CompareSnapshot, i as aggregatePass, j as DESKTOP_VIEWPORT, k as parseViewport, l as runtimeHealthAxis, m as CompareConfig, n as routeSnapshotFile, o as validate, p as saveSnapshot, r as dedupeSharedChrome, s as ALL_AXES, t as compareRoutes, u as compareAxis, v as Region, w as serveStatic, x as diffSnapshots, y as extractRegions } from "./runner-D5dD5L7c.js";
2
2
  //#region src/types.ts
3
3
  const SEVERITIES = [
4
4
  "critical",
@@ -7,6 +7,6 @@ const SEVERITIES = [
7
7
  "minor"
8
8
  ];
9
9
  //#endregion
10
- export { ALL_AXES, CompareConfig, CompareSnapshot, DEFAULT_VIEWPORTS, DESKTOP_VIEWPORT, ExpectedAbsentRule, MOBILE_VIEWPORT, REGION_STATES, Region, SEVERITIES, accessibilityAxis, aggregatePass, briefConformanceAxis, capture, capturePage, captureSnapshot, compareAxis, comparePass, compareRoutes, dedupeSharedChrome, diffSnapshots, extractRegions, loadSnapshot, prepareForScreenshot, routeSnapshotFile, runAxes, runtimeHealthAxis, saveSnapshot, serveStatic, validate, visualBrandAxis };
10
+ export { ALL_AXES, CompareConfig, CompareSnapshot, DEFAULT_VIEWPORTS, DESKTOP_VIEWPORT, ExpectedAbsentRule, MOBILE_VIEWPORT, PHONE_MAX_WIDTH, PHONE_VIEWPORT, REGION_STATES, Region, SEVERITIES, VIEWPORT_PRESETS, accessibilityAxis, aggregatePass, briefConformanceAxis, capture, capturePage, captureSnapshot, compareAxis, comparePass, compareRoutes, dedupeSharedChrome, diffSnapshots, extractRegions, isPhoneViewport, loadSnapshot, parseViewport, prepareForScreenshot, routeSnapshotFile, runAxes, runtimeHealthAxis, saveSnapshot, serveStatic, validate, visualBrandAxis };
11
11
 
12
12
  //# sourceMappingURL=index.js.map
@@ -140,6 +140,65 @@ const capture = async (url, options = {}) => {
140
140
  }
141
141
  };
142
142
  //#endregion
143
+ //#region src/viewport.ts
144
+ /**
145
+ * The phone-first baseline every Keboola screen is designed against.
146
+ *
147
+ * - 375×812 is the narrowest width still in meaningful use; a layout that survives it
148
+ * survives every wider phone.
149
+ * - Distinct from {@link MOBILE_VIEWPORT} (390×844), which is one of `capture`'s two
150
+ * default capture sizes rather than a bar anything is held to.
151
+ */
152
+ const PHONE_VIEWPORT = {
153
+ label: "phone",
154
+ width: 375,
155
+ height: 812
156
+ };
157
+ /** Names `--viewport` accepts in place of a `WIDTHxHEIGHT` pair. */
158
+ const VIEWPORT_PRESETS = {
159
+ desktop: DESKTOP_VIEWPORT,
160
+ mobile: MOBILE_VIEWPORT,
161
+ phone: PHONE_VIEWPORT
162
+ };
163
+ /**
164
+ * Widths below this count as a phone.
165
+ *
166
+ * - 768 is the tablet breakpoint: at or above it a page has room for a desktop-shaped
167
+ * layout, below it the content has one column to live in.
168
+ * - Drives severity, not layout — see {@link isPhoneViewport}.
169
+ */
170
+ const PHONE_MAX_WIDTH = 768;
171
+ /**
172
+ * Whether a viewport is phone-class.
173
+ *
174
+ * - Horizontal overflow here is a broken page, not a cosmetic slip: the reader has to
175
+ * scroll sideways to read a sentence. Axes weigh findings accordingly.
176
+ */
177
+ const isPhoneViewport = (viewport) => viewport.width < 768;
178
+ const DIMENSIONS = /^(\d+)x(\d+)$/;
179
+ /**
180
+ * Read a `--viewport` value: a preset name, or `WIDTHxHEIGHT` in CSS pixels.
181
+ *
182
+ * - Throws on anything else rather than falling back to a default — a typo'd size would
183
+ * otherwise silently evaluate the viewport the caller was trying to move off.
184
+ * - Zero in either dimension is rejected for the same reason; playwright accepts it and
185
+ * renders nothing.
186
+ */
187
+ const parseViewport = (spec) => {
188
+ const trimmed = spec.trim();
189
+ const preset = VIEWPORT_PRESETS[trimmed.toLowerCase()];
190
+ if (preset !== void 0) return preset;
191
+ const match = DIMENSIONS.exec(trimmed);
192
+ const width = Number(match?.[1]);
193
+ const height = Number(match?.[2]);
194
+ if (match === null || width === 0 || height === 0) throw new Error(`--viewport must be WIDTHxHEIGHT (e.g. 375x812) or one of ${Object.keys(VIEWPORT_PRESETS).join(", ")}, got "${spec}"`);
195
+ return {
196
+ label: `${width}x${height}`,
197
+ width,
198
+ height
199
+ };
200
+ };
201
+ //#endregion
143
202
  //#region src/serve.ts
144
203
  const CONTENT_TYPES = {
145
204
  ".html": "text/html; charset=utf-8",
@@ -1056,12 +1115,24 @@ const interactiveContrastFindings = (brand) => checkInteractiveContrast(brand).m
1056
1115
  severity: "moderate",
1057
1116
  detail: `${warning.foreground} on ${warning.background} (${warning.palette})`
1058
1117
  }));
1059
- const overflowFinding = async (page) => {
1060
- if (await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)) return {
1118
+ /**
1119
+ * Horizontal overflow, weighed by the width it was measured at.
1120
+ *
1121
+ * - On a phone viewport it is `critical`: the reader scrolls sideways to finish a
1122
+ * sentence, so the screen is broken rather than untidy. Blocking is the point — the
1123
+ * phone-first rule is only a rule if a gate can go red on it.
1124
+ * - At desktop widths it stays `moderate`, unchanged.
1125
+ */
1126
+ const overflowFinding = async (page, viewport) => {
1127
+ if (!await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)) return null;
1128
+ return isPhoneViewport(viewport) ? {
1129
+ message: `horizontal layout overflow at ${viewport.width}px`,
1130
+ severity: "critical",
1131
+ detail: "The page scrolls sideways on a phone — lay it out in one column instead."
1132
+ } : {
1061
1133
  message: "horizontal layout overflow",
1062
1134
  severity: "moderate"
1063
1135
  };
1064
- return null;
1065
1136
  };
1066
1137
  /**
1067
1138
  * A4 · Visual & brand-correctness verdict — UT-4495.
@@ -1069,9 +1140,10 @@ const overflowFinding = async (page) => {
1069
1140
  * Baseline diff of `context.artifact.screenshot` vs `context.baseline`
1070
1141
  * (pixelmatch + pngjs), plus the alt-brand fitness function via
1071
1142
  * `context.captureUnderBrand`: the chrome must visibly re-skin under an
1072
- * alternate brand. Flags layout overflow. Each check degrades to no finding when
1073
- * its input is absent. (Asserting categorical colors — Badge/Alert/ModalIcon —
1074
- * stay fixed pixel-wise is future work; see `altBrandFinding`.)
1143
+ * alternate brand. Flags layout overflow, blocking at phone widths. Each check
1144
+ * degrades to no finding when its input is absent. (Asserting categorical colors
1145
+ * — Badge/Alert/ModalIcon — stay fixed pixel-wise is future work; see
1146
+ * `altBrandFinding`.)
1075
1147
  */
1076
1148
  const visualBrandAxis = {
1077
1149
  name: "visual-brand",
@@ -1090,7 +1162,7 @@ const visualBrandAxis = {
1090
1162
  if (altBrand !== void 0) findings.push(...interactiveContrastFindings(altBrand));
1091
1163
  }
1092
1164
  if (page !== void 0) {
1093
- const finding = await overflowFinding(page);
1165
+ const finding = await overflowFinding(page, artifact.viewport);
1094
1166
  if (finding !== null) findings.push(finding);
1095
1167
  }
1096
1168
  return {
@@ -1303,6 +1375,6 @@ const compareRoutes = async (options) => {
1303
1375
  }
1304
1376
  };
1305
1377
  //#endregion
1306
- export { prepareForScreenshot as A, accessibilityAxis as C, MOBILE_VIEWPORT as D, DESKTOP_VIEWPORT as E, capture as O, briefConformanceAxis as S, DEFAULT_VIEWPORTS as T, REGION_STATES as _, runAxes as a, comparePass as b, visualBrandAxis as c, captureSnapshot as d, loadSnapshot as f, ExpectedAbsentRule as g, CompareSnapshot as h, aggregatePass as i, capturePage as k, runtimeHealthAxis as l, CompareConfig as m, routeSnapshotFile as n, validate as o, saveSnapshot as p, dedupeSharedChrome as r, ALL_AXES as s, compareRoutes as t, compareAxis as u, Region as v, serveStatic as w, diffSnapshots as x, extractRegions as y };
1378
+ export { DEFAULT_VIEWPORTS as A, accessibilityAxis as C, VIEWPORT_PRESETS as D, PHONE_VIEWPORT as E, prepareForScreenshot as F, MOBILE_VIEWPORT as M, capture as N, isPhoneViewport as O, capturePage as P, briefConformanceAxis as S, PHONE_MAX_WIDTH as T, REGION_STATES as _, runAxes as a, comparePass as b, visualBrandAxis as c, captureSnapshot as d, loadSnapshot as f, ExpectedAbsentRule as g, CompareSnapshot as h, aggregatePass as i, DESKTOP_VIEWPORT as j, parseViewport as k, runtimeHealthAxis as l, CompareConfig as m, routeSnapshotFile as n, validate as o, saveSnapshot as p, dedupeSharedChrome as r, ALL_AXES as s, compareRoutes as t, compareAxis as u, Region as v, serveStatic as w, diffSnapshots as x, extractRegions as y };
1307
1379
 
1308
- //# sourceMappingURL=runner-CLpaiCIG.js.map
1380
+ //# sourceMappingURL=runner-D5dD5L7c.js.map