diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 474d0a0..34b0af2 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -273,9 +273,9 @@ jobs: const minimumFiles = 50; const maximumFiles = 60; const minimumPackedBytes = 140_000; - const maximumPackedBytes = 230_000; + const maximumPackedBytes = 260_000; const minimumUnpackedBytes = 650_000; - const maximumUnpackedBytes = 1_070_000; + const maximumUnpackedBytes = 1_200_000; const maximumMetadataBytes = 250_000; const expectedName = "@hraness/direct"; const expectedVersion = process.env.EXPECTED_VERSION; diff --git a/README.md b/README.md index fa528e8..5735f73 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ real interface and feature state Pin Direct as a development dependency: ```sh -bun add --dev @hraness/direct@0.7.17 +bun add --dev @hraness/direct@0.7.18 # or -npm install --save-dev @hraness/direct@0.7.17 +npm install --save-dev @hraness/direct@0.7.18 ``` Keep Direct in `devDependencies`. A production entry must not import Direct, @@ -58,7 +58,7 @@ composition. It requires Git and Bun 1.3.14, then downloads the source and its development dependencies: ```sh -git clone --branch v0.7.17 --depth 1 https://github.com/hraness/direct.git +git clone --branch v0.7.18 --depth 1 https://github.com/hraness/direct.git cd direct bun install --frozen-lockfile --ignore-scripts bun run example:direct @@ -132,9 +132,9 @@ claim separate as `direct` evidence. Install Direct's single bundled skill from the public repository: ```sh -npx skills add hraness/direct#v0.7.17 +npx skills add hraness/direct#v0.7.18 # or -bunx skills add hraness/direct#v0.7.17 +bunx skills add hraness/direct#v0.7.18 ``` The skill is invoked as `$direct`. It routes installation, adoption, and @@ -148,7 +148,7 @@ Copy this prompt into Codex, Claude Code, or another coding agent: ```text Use $direct to install hraness/direct from -the npm registry at the exact 0.7.17 version. Follow the repository README, add +the npm registry at the exact 0.7.18 version. Follow the repository README, add `@hraness/direct` to devDependencies only, and verify that the production dependency graph excludes Direct. Do not add a fixture composition until I ask. @@ -164,7 +164,7 @@ Pin the public npm package to an exact immutable version: ```json { "devDependencies": { - "@hraness/direct": "0.7.17" + "@hraness/direct": "0.7.18" } } ``` @@ -186,8 +186,8 @@ quiescence, coverage claims, cleanup, and emitted production boundaries. The package smoke test keeps that future packaged copy byte-identical to the repository skill. -Prefer `npx skills add hraness/direct#v0.7.17` or -`bunx skills add hraness/direct#v0.7.17` for runner discovery. You can also copy +Prefer `npx skills add hraness/direct#v0.7.18` or +`bunx skills add hraness/direct#v0.7.18` for runner discovery. You can also copy or link that one skill directory into a runner's configured location, then invoke `$direct`. Package installation leaves the skill inert: it does not run a `postinstall` hook or edit repository or user configuration. @@ -313,7 +313,7 @@ A quiet probe means the declared deterministic work settled. It does not prove t | `@hraness/direct/react` | Typed context, provider, and external-store hooks for React DOM or React Native | Optional React peer | | `@hraness/direct/testing` | Sessions, manifest and probe parsers, evidence classification, activity scopes, and exact scripted transports | Development and verification | | `@hraness/direct/web` | Atomic browser installation, with low-level bridge and firewall escape hatches | Browser only | -| `@hraness/direct/tooling/browser-verification` | Protocol-bound bridge reads, bounded agent-browser commands, local server leases, and artifact writes | Bun 1.3.14 with Node APIs | +| `@hraness/direct/tooling/browser-verification` | Protocol-bound bridge reads, product-owned named-box layout contracts, bounded agent-browser commands, local server leases, and artifact writes | Bun 1.3.14 with Node APIs | | `@hraness/direct/tooling/bombadil-campaign` | Direct property and conservative action factories for a Bombadil specification | Bombadil 0.7.2 specification compiler | | `@hraness/direct/tooling/bombadil` | Local server ownership, native Bombadil lifecycle, serial campaign matrices, trace attestation and summaries, replay, and diagnostic artifacts | Bun 1.3.14 with Node APIs | | `@hraness/direct/tooling/bundle-boundary` | Deterministic emitted-file scans and exact versioned-wire evidence | Bun 1.3.14 with Node APIs | @@ -335,6 +335,10 @@ agent-browser 0.32.3 at `node_modules/.bin/agent-browser` below the supplied `repositoryRoot` and an empty task-owned config at `scripts/direct/agent-browser.verify.json`. The product supplies its explicit launch arguments, allowed domains, scenario commands, and final close policy. +The same tooling subpath parses named layout boxes and explicit product rules +for containment, selected no-overlap pairs, alignment, viewport clipping, +minimum size, and two-sample stability. It does not inspect the DOM or compare +every box pair. ### Fuzz one Direct scenario diff --git a/dist/tooling/browser-verification-entry.js b/dist/tooling/browser-verification-entry.js index 17d938f..bae3141 100644 --- a/dist/tooling/browser-verification-entry.js +++ b/dist/tooling/browser-verification-entry.js @@ -1542,6 +1542,457 @@ async function writeJsonAtomically(path, value) { throw error; } } +// src/tooling/browser-layout-contract.ts +var DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA = "direct.named-layout-sample/v1"; +var DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA = "direct.named-layout-contract/v1"; +var MAX_LAYOUT_BOXES = 128; +var MAX_LAYOUT_RULES = 256; +var MAX_LAYOUT_COORDINATE = 1e7; +var MAX_LAYOUT_TOLERANCE = 1e4; +var MAX_LAYOUT_NAME_LENGTH = 128; +var LAYOUT_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.:/-]*$/u; +var RESERVED_LAYOUT_NAMES = new Set([ + "__proto__", + "constructor", + "prototype" +]); +var SAMPLE_KEYS = new Set(["boxes", "schema", "viewport"]); +var VIEWPORT_KEYS = new Set(["height", "width"]); +var BOX_KEYS = new Set(["height", "name", "width", "x", "y"]); +var CONTRACT_KEYS = new Set(["rules", "schema"]); +var INSIDE_RULE_KEYS = new Set([ + "id", + "inner", + "kind", + "outer", + "tolerance" +]); +var PAIR_RULE_KEYS = new Set([ + "first", + "id", + "kind", + "second", + "tolerance" +]); +var BOX_TOLERANCE_RULE_KEYS = new Set([ + "box", + "id", + "kind", + "tolerance" +]); +var MINIMUM_SIZE_RULE_KEYS = new Set([ + "box", + "id", + "kind", + "minimumHeight", + "minimumWidth" +]); + +class NamedLayoutInputError extends Error { +} +function isRecord2(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function requireExactRecord(value, expected, label) { + if (!isRecord2(value)) { + throw new NamedLayoutInputError(`${label} must be an object`); + } + const keys = Object.keys(value); + if (keys.length !== expected.size || keys.some((key) => !expected.has(key)) || [...expected].some((key) => !Object.hasOwn(value, key))) { + throw new NamedLayoutInputError(`${label} must contain exactly: ${[...expected].sort().join(", ")}`); + } + return value; +} +function requireLayoutName(value, label) { + if (typeof value !== "string" || value.length > MAX_LAYOUT_NAME_LENGTH || !LAYOUT_NAME_PATTERN.test(value) || RESERVED_LAYOUT_NAMES.has(value)) { + throw new NamedLayoutInputError(`${label} must be a safe, unreserved 1-${String(MAX_LAYOUT_NAME_LENGTH)} character identifier`); + } + return value; +} +function requireBoundedNumber(options) { + if (typeof options.value !== "number" || !Number.isFinite(options.value) || options.value < options.minimum || options.value > options.maximum) { + throw new NamedLayoutInputError(`${options.label} must be a finite number between ${String(options.minimum)} and ${String(options.maximum)}`); + } + return options.value; +} +function requireCoordinate(value, label) { + return requireBoundedNumber({ + label, + maximum: MAX_LAYOUT_COORDINATE, + minimum: -MAX_LAYOUT_COORDINATE, + value + }); +} +function requireSize(value, label, positive) { + const size = requireBoundedNumber({ + label, + maximum: MAX_LAYOUT_COORDINATE, + minimum: 0, + value + }); + if (positive && size === 0) { + throw new NamedLayoutInputError(`${label} must be greater than zero`); + } + return size; +} +function requireTolerance(value, label) { + return requireBoundedNumber({ + label, + maximum: MAX_LAYOUT_TOLERANCE, + minimum: 0, + value + }); +} +function parseLayoutBox(input, index) { + const label = `Direct named layout box ${String(index)}`; + const record = requireExactRecord(input, BOX_KEYS, label); + return Object.freeze({ + height: requireSize(record.height, `${label} height`, true), + name: requireLayoutName(record.name, `${label} name`), + width: requireSize(record.width, `${label} width`, true), + x: requireCoordinate(record.x, `${label} x`), + y: requireCoordinate(record.y, `${label} y`) + }); +} +function parseLayoutSampleUnchecked(input) { + const record = requireExactRecord(input, SAMPLE_KEYS, "Direct named layout sample"); + if (record.schema !== DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA) { + throw new NamedLayoutInputError(`Direct named layout sample schema must be ${DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA}`); + } + const viewportRecord = requireExactRecord(record.viewport, VIEWPORT_KEYS, "Direct named layout viewport"); + if (!Array.isArray(record.boxes)) { + throw new NamedLayoutInputError("Direct named layout boxes must be an array"); + } + if (record.boxes.length === 0 || record.boxes.length > MAX_LAYOUT_BOXES) { + throw new NamedLayoutInputError(`Direct named layout samples require 1-${String(MAX_LAYOUT_BOXES)} boxes`); + } + const boxes = []; + for (let index = 0;index < record.boxes.length; index += 1) { + boxes.push(parseLayoutBox(record.boxes[index], index)); + } + const names = new Set; + for (const box of boxes) { + if (names.has(box.name)) { + throw new NamedLayoutInputError(`Direct named layout box name is duplicated: ${box.name}`); + } + names.add(box.name); + } + return Object.freeze({ + boxes: Object.freeze(boxes), + schema: DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA, + viewport: Object.freeze({ + height: requireSize(viewportRecord.height, "Direct named layout viewport height", true), + width: requireSize(viewportRecord.width, "Direct named layout viewport width", true) + }) + }); +} +function parsePairRule(record, kind, id) { + requireExactRecord(record, PAIR_RULE_KEYS, `Direct named layout ${kind} rule`); + const first = requireLayoutName(record.first, `Direct named layout rule ${id} first box`); + const second = requireLayoutName(record.second, `Direct named layout rule ${id} second box`); + if (first === second) { + throw new NamedLayoutInputError(`Direct named layout rule ${id} must name two different boxes`); + } + return Object.freeze({ + first, + id, + kind, + second, + tolerance: requireTolerance(record.tolerance, `Direct named layout rule ${id} tolerance`) + }); +} +function parseBoxToleranceRule(record, kind, id) { + requireExactRecord(record, BOX_TOLERANCE_RULE_KEYS, `Direct named layout ${kind} rule`); + return Object.freeze({ + box: requireLayoutName(record.box, `Direct named layout rule ${id} box`), + id, + kind, + tolerance: requireTolerance(record.tolerance, `Direct named layout rule ${id} tolerance`) + }); +} +function parseLayoutRule(input, index) { + const label = `Direct named layout rule ${String(index)}`; + if (!isRecord2(input)) { + throw new NamedLayoutInputError(`${label} must be an object`); + } + const id = requireLayoutName(input.id, `${label} id`); + switch (input.kind) { + case "inside": { + const record = requireExactRecord(input, INSIDE_RULE_KEYS, `${label} inside`); + const inner = requireLayoutName(record.inner, `${label} inner box`); + const outer = requireLayoutName(record.outer, `${label} outer box`); + if (inner === outer) { + throw new NamedLayoutInputError(`${label} must name different inner and outer boxes`); + } + return Object.freeze({ + id, + inner, + kind: "inside", + outer, + tolerance: requireTolerance(record.tolerance, `${label} tolerance`) + }); + } + case "no-overlap": + case "center-x": + case "center-y": + return parsePairRule(input, input.kind, id); + case "not-clipped": + case "stable": + return parseBoxToleranceRule(input, input.kind, id); + case "minimum-size": { + const record = requireExactRecord(input, MINIMUM_SIZE_RULE_KEYS, `${label} minimum-size`); + const minimumHeight = requireSize(record.minimumHeight, `${label} minimumHeight`, false); + const minimumWidth = requireSize(record.minimumWidth, `${label} minimumWidth`, false); + if (minimumHeight === 0 && minimumWidth === 0) { + throw new NamedLayoutInputError(`${label} must require a positive width or height`); + } + return Object.freeze({ + box: requireLayoutName(record.box, `${label} box`), + id, + kind: "minimum-size", + minimumHeight, + minimumWidth + }); + } + default: + throw new NamedLayoutInputError(`${label} kind must be inside, no-overlap, center-x, center-y, not-clipped, minimum-size, or stable`); + } +} +function parseLayoutContractUnchecked(input) { + const record = requireExactRecord(input, CONTRACT_KEYS, "Direct named layout contract"); + if (record.schema !== DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA) { + throw new NamedLayoutInputError(`Direct named layout contract schema must be ${DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA}`); + } + if (!Array.isArray(record.rules)) { + throw new NamedLayoutInputError("Direct named layout rules must be an array"); + } + if (record.rules.length === 0 || record.rules.length > MAX_LAYOUT_RULES) { + throw new NamedLayoutInputError(`Direct named layout contracts require 1-${String(MAX_LAYOUT_RULES)} rules`); + } + const rules = []; + for (let index = 0;index < record.rules.length; index += 1) { + rules.push(parseLayoutRule(record.rules[index], index)); + } + const ids = new Set; + for (const rule of rules) { + if (ids.has(rule.id)) { + throw new NamedLayoutInputError(`Direct named layout rule id is duplicated: ${rule.id}`); + } + ids.add(rule.id); + } + return Object.freeze({ + rules: Object.freeze(rules), + schema: DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA + }); +} +function parseError(code, error) { + return Object.freeze({ + code, + message: error instanceof NamedLayoutInputError ? error.message : `Direct named layout ${code === "invalid-sample" ? "sample" : "contract"} could not be read` + }); +} +function parseDirectNamedLayoutSample(input) { + try { + return Object.freeze({ ok: true, value: parseLayoutSampleUnchecked(input) }); + } catch (error) { + return Object.freeze({ + error: parseError("invalid-sample", error), + ok: false + }); + } +} +function parseDirectNamedLayoutContract(input) { + try { + return Object.freeze({ ok: true, value: parseLayoutContractUnchecked(input) }); + } catch (error) { + return Object.freeze({ + error: parseError("invalid-contract", error), + ok: false + }); + } +} +function boxMap(sample) { + return new Map(sample.boxes.map((box) => [box.name, box])); +} +function right(box) { + return box.x + box.width; +} +function bottom(box) { + return box.y + box.height; +} +function violation(options) { + return Object.freeze(options); +} +function missingBoxViolation(rule, sample, names) { + return violation({ + code: "missing-box", + message: `Rule ${rule.id} references missing box${names.length === 1 ? "" : "es"}: ${names.join(", ")}`, + ruleId: rule.id, + ruleKind: rule.kind, + sample + }); +} +function pairBoxes(rule, boxes) { + const first = boxes.get(rule.first); + const second = boxes.get(rule.second); + return first === undefined || second === undefined ? null : [first, second]; +} +function validateStaticRule(rule, sampleName, sample, boxes) { + switch (rule.kind) { + case "inside": { + const inner = boxes.get(rule.inner); + const outer = boxes.get(rule.outer); + if (inner === undefined || outer === undefined) { + return missingBoxViolation(rule, sampleName, [inner === undefined ? rule.inner : null, outer === undefined ? rule.outer : null].filter((name) => name !== null)); + } + if (inner.x < outer.x - rule.tolerance || inner.y < outer.y - rule.tolerance || right(inner) > right(outer) + rule.tolerance || bottom(inner) > bottom(outer) + rule.tolerance) { + return violation({ + code: "outside", + message: `Box ${rule.inner} is not inside ${rule.outer} within ${String(rule.tolerance)} CSS pixels`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: sampleName + }); + } + return null; + } + case "no-overlap": { + const pair = pairBoxes(rule, boxes); + if (pair === null) { + return missingBoxViolation(rule, sampleName, [rule.first, rule.second].filter((name) => !boxes.has(name))); + } + const [first, second] = pair; + const overlapWidth = Math.min(right(first), right(second)) - Math.max(first.x, second.x); + const overlapHeight = Math.min(bottom(first), bottom(second)) - Math.max(first.y, second.y); + if (overlapWidth > rule.tolerance && overlapHeight > rule.tolerance) { + return violation({ + code: "overlap", + message: `Boxes ${rule.first} and ${rule.second} overlap beyond ${String(rule.tolerance)} CSS pixels`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: sampleName + }); + } + return null; + } + case "center-x": + case "center-y": { + const pair = pairBoxes(rule, boxes); + if (pair === null) { + return missingBoxViolation(rule, sampleName, [rule.first, rule.second].filter((name) => !boxes.has(name))); + } + const [first, second] = pair; + const firstCenter = rule.kind === "center-x" ? first.x + first.width / 2 : first.y + first.height / 2; + const secondCenter = rule.kind === "center-x" ? second.x + second.width / 2 : second.y + second.height / 2; + if (Math.abs(firstCenter - secondCenter) > rule.tolerance) { + return violation({ + code: "misaligned", + message: `Boxes ${rule.first} and ${rule.second} are not ${rule.kind} aligned within ${String(rule.tolerance)} CSS pixels`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: sampleName + }); + } + return null; + } + case "not-clipped": { + const box = boxes.get(rule.box); + if (box === undefined) { + return missingBoxViolation(rule, sampleName, [rule.box]); + } + if (box.x < -rule.tolerance || box.y < -rule.tolerance || right(box) > sample.viewport.width + rule.tolerance || bottom(box) > sample.viewport.height + rule.tolerance) { + return violation({ + code: "clipped", + message: `Box ${rule.box} extends outside the viewport beyond ${String(rule.tolerance)} CSS pixels`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: sampleName + }); + } + return null; + } + case "minimum-size": { + const box = boxes.get(rule.box); + if (box === undefined) { + return missingBoxViolation(rule, sampleName, [rule.box]); + } + if (box.width < rule.minimumWidth || box.height < rule.minimumHeight) { + return violation({ + code: "too-small", + message: `Box ${rule.box} is smaller than ${String(rule.minimumWidth)} by ${String(rule.minimumHeight)} CSS pixels`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: sampleName + }); + } + return null; + } + } +} +function validateStabilityRule(rule, firstSample, secondSample, first, second) { + if (secondSample === undefined || second === null) { + return violation({ + code: "second-sample-required", + message: `Rule ${rule.id} requires two layout samples`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: "pair" + }); + } + if (firstSample.viewport.width !== secondSample.viewport.width || firstSample.viewport.height !== secondSample.viewport.height) { + return violation({ + code: "viewport-changed", + message: `Rule ${rule.id} requires two samples at the same viewport`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: "pair" + }); + } + const firstBox = first.get(rule.box); + const secondBox = second.get(rule.box); + if (firstBox === undefined || secondBox === undefined) { + return missingBoxViolation(rule, "pair", [firstBox === undefined ? `first:${rule.box}` : null, secondBox === undefined ? `second:${rule.box}` : null].filter((name) => name !== null)); + } + if (Math.abs(firstBox.x - secondBox.x) > rule.tolerance || Math.abs(firstBox.y - secondBox.y) > rule.tolerance || Math.abs(firstBox.width - secondBox.width) > rule.tolerance || Math.abs(firstBox.height - secondBox.height) > rule.tolerance) { + return violation({ + code: "unstable", + message: `Box ${rule.box} changed between samples beyond ${String(rule.tolerance)} CSS pixels`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: "pair" + }); + } + return null; +} +function validateDirectNamedLayout(contract, samples) { + if (!Array.isArray(samples) || samples.length !== 1 && samples.length !== 2) { + throw new RangeError("Direct named layout validation requires exactly one or two parsed samples"); + } + const violations = []; + const firstBoxes = boxMap(samples[0]); + const secondSample = samples[1]; + const secondBoxes = secondSample === undefined ? null : boxMap(secondSample); + for (const rule of contract.rules) { + if (rule.kind === "stable") { + const found = validateStabilityRule(rule, samples[0], secondSample, firstBoxes, secondBoxes); + if (found !== null) + violations.push(found); + continue; + } + const firstViolation = validateStaticRule(rule, "first", samples[0], firstBoxes); + if (firstViolation !== null) + violations.push(firstViolation); + if (secondSample !== undefined && secondBoxes !== null) { + const secondViolation = validateStaticRule(rule, "second", secondSample, secondBoxes); + if (secondViolation !== null) + violations.push(secondViolation); + } + } + return Object.freeze({ + ok: violations.length === 0, + violations: Object.freeze(violations) + }); +} // src/tooling/browser-verification-entry.ts var readDirectBrowserContract = createDirectBrowserContractReader({ @@ -1551,6 +2002,7 @@ var readDirectBrowserContract = createDirectBrowserContractReader({ }); export { writeJsonAtomically, + validateDirectNamedLayout, tail, stopVerificationServer, spawnVerificationServer, @@ -1560,6 +2012,8 @@ export { renderUnknown, renderAgentBrowserCommand, readDirectBrowserContract, + parseDirectNamedLayoutSample, + parseDirectNamedLayoutContract, parseBaseUrlArguments, parseAgentBrowserEnvelope, parseAgentBrowserBatchEnvelope, @@ -1574,5 +2028,7 @@ export { bindDirectBrowserContractEvidence, agentBrowserProcessTimeoutMs, agentBrowserCloseProcessTimeoutMs, - acquireVerificationServer + acquireVerificationServer, + DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA, + DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA }; diff --git a/docs/adoption.md b/docs/adoption.md index 7cf9dd0..c1f17f4 100644 --- a/docs/adoption.md +++ b/docs/adoption.md @@ -3,8 +3,8 @@ Add Direct after identifying the product behavior and external boundary under review. Do not begin by designing fixtures around a provider SDK. The repository carries one `$direct` Agent Skill under `skills/direct`. -Install it with `npx skills add hraness/direct#v0.7.17` or -`bunx skills add hraness/direct#v0.7.17`, or copy that directory into the runner's +Install it with `npx skills add hraness/direct#v0.7.18` or +`bunx skills add hraness/direct#v0.7.18`, or copy that directory into the runner's discovery location. Invoke `$direct` for installation, adoption, and verification work. Skill installation is independent from library package installation and does not activate Direct in a product. diff --git a/docs/publishing.md b/docs/publishing.md index 4929d7f..6209d86 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -62,8 +62,8 @@ cookie, one-time password, recovery code, or write token to GitHub. `v`. ```sh - git tag v0.7.17 - git push origin refs/tags/v0.7.17 + git tag v0.7.18 + git push origin refs/tags/v0.7.18 ``` 3. Wait for **Release**. The workflow runs these boundaries in order: @@ -123,7 +123,7 @@ Merge the fix to `main`, then dispatch **Release** from current `main` with the exact existing stable tag: ```sh -gh workflow run release.yml --ref main -f tag=v0.7.17 +gh workflow run release.yml --ref main -f tag=v0.7.18 ``` The recovery path skips npm publication. It accepts only the newest stable diff --git a/docs/verification.md b/docs/verification.md index 664eef7..931ee08 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -10,8 +10,8 @@ definition with `parseDefinitionCoverageSnapshot` so a valid but stale catalog cannot be mistaken for the catalog under review. The repository carries one `$direct` Agent Skill under `skills/direct`. -Install it with `npx skills add hraness/direct#v0.7.17` or -`bunx skills add hraness/direct#v0.7.17`, or copy that directory into the runner's +Install it with `npx skills add hraness/direct#v0.7.18` or +`bunx skills add hraness/direct#v0.7.18`, or copy that directory into the runner's discovery location. Invoke `$direct` for the workflow below. The skill is independent from library package installation and structures the audit; it does not turn deterministic evidence into proof of a substituted live system. @@ -211,6 +211,103 @@ those decisions in the product verifier. Under the canonical workflow, capture visual evidence from the same exact local Chromium context that produced the manifest, probe, actions, and semantic assertions. +## Separate visual critique from layout gates + +A design review needs evidence at two scales. At the macro scale, inspect a +bounded screenshot set for hierarchy, grouping, density, reading order, +progressive disclosure, and responsive composition. Those judgments depend on +the product and remain a reviewed visual critique. At the micro scale, turn a +known spatial relationship into a deterministic named-box gate. Geometry can +catch a footer form that is off center or a label that overlaps its value, but +it cannot decide whether the page has a clear visual hierarchy. + +The Bun/Node `@hraness/direct/tooling/browser-verification` subpath exports a +driver-neutral named layout contract. The product's browser evaluator measures +only the elements in scope and returns a versioned sample. Parse that foreign +value with `parseDirectNamedLayoutSample`. Parse the reviewed product rules +with `parseDirectNamedLayoutContract`, then pass the parsed values to +`validateDirectNamedLayout`: + +```ts +import { + DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA, + parseDirectNamedLayoutContract, + parseDirectNamedLayoutSample, + validateDirectNamedLayout, +} from "@hraness/direct/tooling/browser-verification"; + +const parsedContract = parseDirectNamedLayoutContract({ + schema: DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA, + rules: [ + { + id: "footer.visible-controls-centered", + kind: "center-y", + first: "footer.brand", + second: "footer.visible-controls", + tolerance: 1, + }, + { + id: "result.label-clear-of-value", + kind: "no-overlap", + first: "result.label", + second: "result.value", + tolerance: 0, + }, + { + id: "footer.stable", + kind: "stable", + box: "footer.visible-controls", + tolerance: 1, + }, + ], +}); +if (!parsedContract.ok) throw new Error(parsedContract.error.message); + +const first = parseDirectNamedLayoutSample(await readProductLayout()); +if (!first.ok) throw new Error(first.error.message); + +// Rejoin product quiescence and the product's bounded render-settle boundary. +const second = parseDirectNamedLayoutSample(await readProductLayout()); +if (!second.ok) throw new Error(second.error.message); + +const layout = validateDirectNamedLayout(parsedContract.value, [ + first.value, + second.value, +]); +if (!layout.ok) { + throw new Error(layout.violations.map(({ message }) => message).join("\n")); +} +``` + +Each sample uses schema `direct.named-layout-sample/v1`, contains +`viewport: { width, height }`, and carries one to 128 boxes named by the +product. A box contains `name`, `x`, `y`, `width`, and `height` in CSS pixels +relative to that viewport. Include only resolved, visible rectangles with +positive width and height; omit hidden or absent elements so a required name +fails closed. Keep both samples in the same browser context and at the same +viewport. The validator accepts exactly one or two samples at runtime. The +`stable` rule compares all four numeric box fields across exactly two samples. +Static rules run against both samples. + +The contract supports these explicit rules: + +- `inside` keeps one named box within another, with a CSS-pixel tolerance. +- `no-overlap` checks one named pair. It allows edge contact and the declared + shallow overlap tolerance. +- `center-x` and `center-y` compare the named box centers within a tolerance. +- `not-clipped` keeps a box within the measured viewport. To check an + overflow-clipping ancestor, name that ancestor and use `inside` too. +- `minimum-size` requires the declared width and height. Set one minimum to + zero when only the other axis is constrained. +- `stable` requires a second sample and bounds changes to position and size. + +Direct never generates all box pairs. Intended overlays, hidden disclosures, +offscreen content, and nested controls make a global collision scan noisy and +can hide the relationships that matter. Name each rule after the product +invariant, run the contract at the viewports where that invariant applies, and +keep the screenshot critique for typography, contrast, rhythm, prominence, +and other visual qualities that rectangles do not express. + ## Tear down the browser batch After the final scenario evidence, retain one last tab inventory and every diff --git a/package.json b/package.json index b760ac1..e3770f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hraness/direct", - "version": "0.7.17", + "version": "0.7.18", "description": "A TypeScript harness for deterministic frontend testing and development with repeatable scenarios, local fixtures, and browser verification for coding agents.", "license": "MIT", "type": "module", @@ -98,6 +98,7 @@ "src/testing/scripted-transport.ts", "src/testing/session.ts", "src/tooling/browser-verification-entry.ts", + "src/tooling/browser-layout-contract.ts", "src/tooling/browser-verification.ts", "src/tooling/bombadil-internal.d.ts", "src/tooling/bombadil-campaign.ts", diff --git a/scripts/npm-publish-workflow.test.ts b/scripts/npm-publish-workflow.test.ts index db96df6..2980bf1 100644 --- a/scripts/npm-publish-workflow.test.ts +++ b/scripts/npm-publish-workflow.test.ts @@ -341,7 +341,7 @@ import { isUtf8ByteLengthAtMost } from "./utf8-byte-boundary.js"; readonly version?: unknown; }; expect(manifest).toEqual(expect.objectContaining({ - version: "0.7.17", + version: "0.7.18", description: "A TypeScript harness for deterministic frontend testing and development with repeatable scenarios, local fixtures, and browser verification for coding agents.", keywords: [ "frontend-development", @@ -475,9 +475,9 @@ import { isUtf8ByteLengthAtMost } from "./utf8-byte-boundary.js"; "const minimumFiles = 50", "const maximumFiles = 60", "const minimumPackedBytes = 140_000", - "const maximumPackedBytes = 230_000", + "const maximumPackedBytes = 260_000", "const minimumUnpackedBytes = 650_000", - "const maximumUnpackedBytes = 1_070_000", + "const maximumUnpackedBytes = 1_200_000", "record.files.length !== record.entryCount", "unpackedSize !== record.unpackedSize", 'createHash("sha1")', diff --git a/scripts/package-artifact.ts b/scripts/package-artifact.ts index 66a8cb4..3e9044a 100644 --- a/scripts/package-artifact.ts +++ b/scripts/package-artifact.ts @@ -9,8 +9,8 @@ const maximumTarBytes = 2_000_000; const packageBudget = Object.freeze({ entryCount: { min: 50, max: 120 }, fileCount: { min: 50, max: 60 }, - packedBytes: { min: 140_000, max: 230_000 }, - unpackedBytes: { min: 650_000, max: 1_070_000 }, + packedBytes: { min: 140_000, max: 260_000 }, + unpackedBytes: { min: 650_000, max: 1_200_000 }, }); const requiredPaths = Object.freeze([ diff --git a/scripts/package-smoke.ts b/scripts/package-smoke.ts index 9197f9b..dfa25e5 100644 --- a/scripts/package-smoke.ts +++ b/scripts/package-smoke.ts @@ -562,6 +562,10 @@ const supportsBombadilBoaNamedSnapshots = Bun.semver.order( packageManifest.version, "0.7.10", ) >= 0; +const supportsNamedLayoutContracts = Bun.semver.order( + packageManifest.version, + "0.7.18", +) >= 0; const work = await mkdtemp(join(tmpdir(), "hraness-package-smoke-")); try { const packageInput = parsePackageInput(process.argv.slice(2), repository); @@ -709,6 +713,13 @@ try { normalizeRootHttpOrigin, readDirectBrowserContract, } from "@hraness/direct/tooling/browser-verification"; + ${supportsNamedLayoutContracts ? `import { + DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA, + DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA, + parseDirectNamedLayoutContract, + parseDirectNamedLayoutSample, + validateDirectNamedLayout, + } from "@hraness/direct/tooling/browser-verification";` : ""} ${bombadilRuntimeImports(bombadilFeatureProfile)} import { findForbiddenMarkers } from "@hraness/direct/tooling/bundle-boundary"; @@ -725,6 +736,28 @@ try { if (typeof readDirectBrowserContract !== "function") { throw new Error("the package-bound Direct browser reader is missing"); } + ${supportsNamedLayoutContracts ? `const layoutSample = parseDirectNamedLayoutSample({ + schema: DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA, + viewport: { width: 100, height: 100 }, + boxes: [{ name: "control", x: 10, y: 10, width: 44, height: 44 }], + }); + const layoutContract = parseDirectNamedLayoutContract({ + schema: DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA, + rules: [{ + id: "control.minimum", + kind: "minimum-size", + box: "control", + minimumWidth: 44, + minimumHeight: 44, + }], + }); + if ( + !layoutSample.ok + || !layoutContract.ok + || !validateDirectNamedLayout(layoutContract.value, [layoutSample.value]).ok + ) { + throw new Error("the package-bound named layout contract is invalid"); + }` : ""} if (typeof runDirectBombadilFuzz !== "function") { throw new Error("Bombadil host tooling runner is missing"); } diff --git a/skills/direct/SKILL.md b/skills/direct/SKILL.md index 290ee95..09226ef 100644 --- a/skills/direct/SKILL.md +++ b/skills/direct/SKILL.md @@ -37,6 +37,13 @@ explicit activation instead of falling back to another scenario. Use logical time and activity scopes for deterministic work, and join a stable quiet probe before making semantic assertions. +For a design or layout audit, pair a macro screenshot critique with micro +geometry gates from `@hraness/direct/tooling/browser-verification`. Let the +product name the measured boxes, no-overlap pairs, containment, alignment, +clipping, size, and stability rules. Never substitute automatic all-pairs +collision scanning for reviewed product invariants, and never treat a passing +rectangle gate as proof of visual hierarchy or quality. + State proof limits precisely. Fixture evidence can prove the real interface and product logic through deterministic ports; it cannot prove a substituted adapter, service, operating system, browser host, or device. diff --git a/skills/direct/references/install.md b/skills/direct/references/install.md index 9715aa5..a886c33 100644 --- a/skills/direct/references/install.md +++ b/skills/direct/references/install.md @@ -21,9 +21,9 @@ global `direct` CLI. For a new installation, pin the reviewed public release: ```sh -bun add --dev @hraness/direct@0.7.17 +bun add --dev @hraness/direct@0.7.18 # or, in an npm project -npm install --save-dev @hraness/direct@0.7.17 +npm install --save-dev @hraness/direct@0.7.18 ``` The equivalent manifest entry is: @@ -31,7 +31,7 @@ The equivalent manifest entry is: ```json { "devDependencies": { - "@hraness/direct": "0.7.17" + "@hraness/direct": "0.7.18" } } ``` @@ -43,8 +43,8 @@ If the task starts with skill installation rather than a loaded skill, install the single repository skill with either command: ```sh -npx skills add hraness/direct#v0.7.17 -bunx skills add hraness/direct#v0.7.17 +npx skills add hraness/direct#v0.7.18 +bunx skills add hraness/direct#v0.7.18 ``` Restart or reload the agent runner if it does not discover newly installed diff --git a/skills/direct/references/verification.md b/skills/direct/references/verification.md index 7506c91..8411800 100644 --- a/skills/direct/references/verification.md +++ b/skills/direct/references/verification.md @@ -32,6 +32,19 @@ artifacts when they fit the repository. They invoke the consumer's local agent-browser installation; they do not bundle a driver, coordinate parallel work, supervise cleanup, or own product commands and evidence. +For a visual or layout critique, separate macro review from micro gates. Review +screenshots at the product's selected routes and viewports for hierarchy, +grouping, density, reading order, and responsive composition. Then measure a +small set of product-named boxes and use the same tooling subpath's strict +named layout sample and contract parsers. Add only reviewed rules for `inside`, +named `no-overlap` pairs, `center-x`, `center-y`, `not-clipped`, +`minimum-size`, and two-sample `stable` checks. Collect both stability samples +in the same browser context and viewport around the product's bounded +render-settle boundary. Do not compare every pair automatically. A geometry +gate catches declared spatial regressions; it does not judge typography, +contrast, rhythm, prominence, or overall design quality. Keep those conclusions +in the screenshot critique. + When a product already has a Bombadil campaign, prefer the shared `@hraness/direct/tooling/bombadil-campaign` factories and `@hraness/direct/tooling/bombadil` host runner instead of copying Direct diff --git a/src/exports.test.ts b/src/exports.test.ts index 11a73c8..4953eed 100644 --- a/src/exports.test.ts +++ b/src/exports.test.ts @@ -77,7 +77,16 @@ describe("public package exports", () => { ]); expect(typeof browserVerification.createAgentBrowser).toBe("function"); expect(typeof browserVerification.createDirectBrowserContractReader).toBe("function"); + expect(browserVerification.DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA).toBe( + "direct.named-layout-sample/v1", + ); + expect(browserVerification.DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA).toBe( + "direct.named-layout-contract/v1", + ); + expect(typeof browserVerification.parseDirectNamedLayoutSample).toBe("function"); + expect(typeof browserVerification.parseDirectNamedLayoutContract).toBe("function"); expect(typeof browserVerification.readDirectBrowserContract).toBe("function"); + expect(typeof browserVerification.validateDirectNamedLayout).toBe("function"); expect(typeof bombadil.runDirectBombadilFuzz).toBe("function"); expect(typeof bombadil.attestDirectBombadilTrace).toBe("function"); expect(typeof bombadil.parseDirectBombadilArtifactReceipt).toBe("function"); @@ -90,9 +99,12 @@ describe("public package exports", () => { expect(typeof bundleBoundary.checkBundleBoundary).toBe("function"); expect(typeof bundleBoundary.findForbiddenMarkers).toBe("function"); expect("createAgentBrowser" in root).toBeFalse(); + expect("validateDirectNamedLayout" in root).toBeFalse(); expect("checkBundleBoundary" in root).toBeFalse(); expect("createAgentBrowser" in web).toBeFalse(); + expect("validateDirectNamedLayout" in web).toBeFalse(); expect("checkBundleBoundary" in testing).toBeFalse(); + expect("validateDirectNamedLayout" in testing).toBeFalse(); type PublicRunnerArity = Parameters["length"]; const supportedRunnerArities: readonly PublicRunnerArity[] = [1, 2]; const supportedRunOptions: DirectBombadilFuzzRunInput = { diff --git a/src/tooling/browser-layout-contract.test.ts b/src/tooling/browser-layout-contract.test.ts new file mode 100644 index 0000000..b7d6cb8 --- /dev/null +++ b/src/tooling/browser-layout-contract.test.ts @@ -0,0 +1,473 @@ +import { describe, expect, test } from "bun:test"; + +import { + DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA, + DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA, + parseDirectNamedLayoutContract, + parseDirectNamedLayoutSample, + validateDirectNamedLayout, + type DirectNamedLayoutContract, + type DirectNamedLayoutSample, +} from "./browser-verification-entry.js"; + +function sampleInput(overrides: Readonly> = {}): unknown { + return { + schema: DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA, + viewport: { width: 800, height: 600 }, + boxes: [ + { name: "page", x: 0, y: 0, width: 800, height: 600 }, + { name: "panel", x: 50, y: 60, width: 500, height: 300 }, + { name: "control", x: 200, y: 168, width: 200, height: 44 }, + { name: "label", x: 80, y: 170, width: 100, height: 40 }, + { name: "footer", x: 0, y: 520, width: 800, height: 80 }, + { name: "footer.brand", x: 40, y: 540, width: 120, height: 40 }, + { name: "footer.form", x: 300, y: 540, width: 220, height: 40 }, + { name: "unlisted.one", x: 600, y: 100, width: 80, height: 80 }, + { name: "unlisted.two", x: 620, y: 120, width: 80, height: 80 }, + ], + ...overrides, + }; +} + +function contractInput(rules: readonly unknown[]): unknown { + return { + schema: DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA, + rules, + }; +} + +function parseSample(input: unknown = sampleInput()): DirectNamedLayoutSample { + const parsed = parseDirectNamedLayoutSample(input); + if (!parsed.ok) throw new Error(parsed.error.message); + return parsed.value; +} + +function parseContract(rules: readonly unknown[]): DirectNamedLayoutContract { + const parsed = parseDirectNamedLayoutContract(contractInput(rules)); + if (!parsed.ok) throw new Error(parsed.error.message); + return parsed.value; +} + +const passingRules = [ + { + id: "control.inside-panel", + kind: "inside", + inner: "control", + outer: "panel", + tolerance: 0, + }, + { + id: "label.clear-of-control", + kind: "no-overlap", + first: "label", + second: "control", + tolerance: 0, + }, + { + id: "control.centered-in-panel", + kind: "center-x", + first: "control", + second: "panel", + tolerance: 0, + }, + { + id: "footer.controls-centered", + kind: "center-y", + first: "footer.brand", + second: "footer.form", + tolerance: 1, + }, + { + id: "footer.visible", + kind: "not-clipped", + box: "footer", + tolerance: 1, + }, + { + id: "control.tap-size", + kind: "minimum-size", + box: "control", + minimumWidth: 44, + minimumHeight: 44, + }, + { + id: "footer.stable", + kind: "stable", + box: "footer", + tolerance: 1, + }, +] as const; + +describe("Direct named layout input", () => { + test("parses and freezes bounded named boxes and product rules", () => { + const sample = parseSample(); + const contract = parseContract(passingRules); + + expect(sample.boxes).toHaveLength(9); + expect(Object.isFrozen(sample)).toBeTrue(); + expect(Object.isFrozen(sample.boxes)).toBeTrue(); + expect(Object.isFrozen(sample.boxes[0])).toBeTrue(); + expect(Object.isFrozen(sample.viewport)).toBeTrue(); + expect(contract.rules.map((rule) => rule.kind)).toEqual([ + "inside", + "no-overlap", + "center-x", + "center-y", + "not-clipped", + "minimum-size", + "stable", + ]); + expect(Object.isFrozen(contract)).toBeTrue(); + expect(Object.isFrozen(contract.rules)).toBeTrue(); + expect(Object.isFrozen(contract.rules[0])).toBeTrue(); + }); + + test("rejects unknown fields at every versioned boundary", () => { + const mutations = [ + { ...sampleInput() as object, extra: true }, + sampleInput({ viewport: { width: 800, height: 600, extra: true } }), + sampleInput({ + boxes: [{ name: "page", x: 0, y: 0, width: 800, height: 600, extra: true }], + }), + ]; + for (const mutation of mutations) { + const parsed = parseDirectNamedLayoutSample(mutation); + expect(parsed.ok).toBeFalse(); + if (!parsed.ok) expect(parsed.error.code).toBe("invalid-sample"); + } + + for (const mutation of [ + { ...contractInput(passingRules) as object, extra: true }, + contractInput([{ ...passingRules[0], extra: true }]), + ]) { + const parsed = parseDirectNamedLayoutContract(mutation); + expect(parsed.ok).toBeFalse(); + if (!parsed.ok) expect(parsed.error.code).toBe("invalid-contract"); + } + }); + + test("rejects duplicate, unsafe, unbounded, and vacuous declarations", () => { + const badSamples = [ + sampleInput({ + boxes: [ + { name: "page", x: 0, y: 0, width: 800, height: 600 }, + { name: "page", x: 1, y: 1, width: 10, height: 10 }, + ], + }), + sampleInput({ + boxes: [{ name: "__proto__", x: 0, y: 0, width: 1, height: 1 }], + }), + sampleInput({ viewport: { width: 0, height: 600 } }), + sampleInput({ + boxes: [{ name: "hidden", x: 0, y: 0, width: 0, height: 0 }], + }), + sampleInput({ + boxes: [{ name: "page", x: Number.POSITIVE_INFINITY, y: 0, width: 1, height: 1 }], + }), + ]; + for (const input of badSamples) { + expect(parseDirectNamedLayoutSample(input).ok).toBeFalse(); + } + + const badContracts = [ + contractInput([passingRules[0], passingRules[0]]), + contractInput([{ + id: "same.inside", + kind: "inside", + inner: "panel", + outer: "panel", + tolerance: 0, + }]), + contractInput([{ + id: "empty.minimum", + kind: "minimum-size", + box: "control", + minimumWidth: 0, + minimumHeight: 0, + }]), + ]; + for (const input of badContracts) { + expect(parseDirectNamedLayoutContract(input).ok).toBeFalse(); + } + }); + + test("fails closed without reflecting an accessor error", () => { + const secret = "SECRET_LAYOUT_ACCESSOR_TOKEN"; + const input = new Proxy({}, { + ownKeys: () => { + throw new Error(secret); + }, + }); + + const parsed = parseDirectNamedLayoutSample(input); + expect(parsed.ok).toBeFalse(); + if (!parsed.ok) { + expect(parsed.error.message).not.toContain(secret); + expect(parsed.error.message).toContain("could not be read"); + } + }); +}); + +describe("Direct named layout validation", () => { + test("passes every explicit rule across a stable two-sample pair", () => { + const first = parseSample(); + const second = parseSample(sampleInput({ + boxes: first.boxes.map((box) => box.name === "footer" + ? { ...box, y: box.y + 0.5 } + : box), + })); + const result = validateDirectNamedLayout(parseContract(passingRules), [ + first, + second, + ]); + + expect(result).toEqual({ ok: true, violations: [] }); + }); + + test("does not compare unnamed pairs automatically", () => { + const contract = parseContract([{ + id: "footer.visible", + kind: "not-clipped", + box: "footer", + tolerance: 0, + }]); + + expect(validateDirectNamedLayout(contract, [parseSample()])).toEqual({ + ok: true, + violations: [], + }); + }); + + test("locks overlap and edge-contact tolerance boundaries", () => { + const contract = parseContract([{ + id: "boxes.clear", + kind: "no-overlap", + first: "first", + second: "second", + tolerance: 1, + }]); + const withSecondAt = (x: number) => parseSample(sampleInput({ + boxes: [ + { name: "first", x: 10, y: 10, width: 20, height: 20 }, + { name: "second", x, y: 10, width: 20, height: 20 }, + ], + })); + + expect(validateDirectNamedLayout(contract, [withSecondAt(30)]).ok).toBeTrue(); + expect(validateDirectNamedLayout(contract, [withSecondAt(29)]).ok).toBeTrue(); + expect(validateDirectNamedLayout(contract, [withSecondAt(28.9)]).violations[0]) + .toMatchObject({ code: "overlap", ruleId: "boxes.clear" }); + }); + + test("locks containment and clipping tolerance boundaries", () => { + const contract = parseContract([ + { + id: "inner.contained", + kind: "inside", + inner: "inner", + outer: "outer", + tolerance: 1, + }, + { + id: "inner.visible", + kind: "not-clipped", + box: "inner", + tolerance: 1, + }, + ]); + const atBoundary = parseSample(sampleInput({ + viewport: { width: 100, height: 100 }, + boxes: [ + { name: "outer", x: 0, y: 0, width: 100, height: 100 }, + { name: "inner", x: -1, y: -1, width: 102, height: 102 }, + ], + })); + const beyondBoundary = parseSample(sampleInput({ + viewport: { width: 100, height: 100 }, + boxes: [ + { name: "outer", x: 0, y: 0, width: 100, height: 100 }, + { name: "inner", x: -1.1, y: -1.1, width: 102.2, height: 102.2 }, + ], + })); + + expect(validateDirectNamedLayout(contract, [atBoundary]).ok).toBeTrue(); + expect(validateDirectNamedLayout(contract, [beyondBoundary]).violations.map( + ({ code }) => code, + )).toEqual(["outside", "clipped"]); + }); + + test("reports each failed geometry rule with its owned id", () => { + const contract = parseContract([ + { + id: "outside", + kind: "inside", + inner: "footer", + outer: "panel", + tolerance: 0, + }, + { + id: "overlap", + kind: "no-overlap", + first: "unlisted.one", + second: "unlisted.two", + tolerance: 0, + }, + { + id: "center-x", + kind: "center-x", + first: "label", + second: "control", + tolerance: 1, + }, + { + id: "center-y", + kind: "center-y", + first: "label", + second: "footer.form", + tolerance: 1, + }, + { + id: "clipped", + kind: "not-clipped", + box: "panel", + tolerance: 0, + }, + { + id: "small", + kind: "minimum-size", + box: "label", + minimumWidth: 101, + minimumHeight: 41, + }, + ]); + const clipped = parseSample(sampleInput({ + boxes: (parseSample().boxes).map((box) => box.name === "panel" + ? { ...box, x: 700 } + : box), + })); + const result = validateDirectNamedLayout(contract, [clipped]); + + expect(result.ok).toBeFalse(); + expect(result.violations.map(({ code, ruleId, sample }) => ({ + code, + ruleId, + sample, + }))).toEqual([ + { code: "outside", ruleId: "outside", sample: "first" }, + { code: "overlap", ruleId: "overlap", sample: "first" }, + { code: "misaligned", ruleId: "center-x", sample: "first" }, + { code: "misaligned", ruleId: "center-y", sample: "first" }, + { code: "clipped", ruleId: "clipped", sample: "first" }, + { code: "too-small", ruleId: "small", sample: "first" }, + ]); + }); + + test("requires the named boxes in each sample", () => { + const contract = parseContract([{ + id: "missing.pair", + kind: "no-overlap", + first: "label", + second: "absent", + tolerance: 0, + }]); + const result = validateDirectNamedLayout(contract, [parseSample()]); + + expect(result.ok).toBeFalse(); + expect(result.violations[0]).toMatchObject({ + code: "missing-box", + ruleId: "missing.pair", + sample: "first", + }); + }); + + test("requires a second sample and rejects movement beyond tolerance", () => { + const contract = parseContract([{ + id: "footer.stable", + kind: "stable", + box: "footer", + tolerance: 1, + }]); + const first = parseSample(); + + expect(validateDirectNamedLayout(contract, [first]).violations[0]).toMatchObject({ + code: "second-sample-required", + sample: "pair", + }); + + const resized = parseSample(sampleInput({ + viewport: { width: 801, height: 600 }, + })); + expect(validateDirectNamedLayout(contract, [first, resized]).violations[0]) + .toMatchObject({ code: "viewport-changed", sample: "pair" }); + + const second = parseSample(sampleInput({ + boxes: first.boxes.map((box) => box.name === "footer" + ? { ...box, y: box.y - 2 } + : box), + })); + expect(validateDirectNamedLayout(contract, [first, second]).violations[0]) + .toMatchObject({ code: "unstable", sample: "pair" }); + }); + + test("checks stability tolerance across position and size", () => { + const contract = parseContract([{ + id: "footer.stable", + kind: "stable", + box: "footer", + tolerance: 1, + }]); + const first = parseSample(); + for (const field of ["x", "y", "width", "height"] as const) { + const atBoundary = parseSample(sampleInput({ + boxes: first.boxes.map((box) => box.name === "footer" + ? { ...box, [field]: box[field] + 1 } + : box), + })); + expect(validateDirectNamedLayout(contract, [first, atBoundary]).ok).toBeTrue(); + + const beyondBoundary = parseSample(sampleInput({ + boxes: first.boxes.map((box) => box.name === "footer" + ? { ...box, [field]: box[field] + 1.1 } + : box), + })); + expect(validateDirectNamedLayout(contract, [first, beyondBoundary]).violations[0]) + .toMatchObject({ code: "unstable", sample: "pair" }); + } + }); + + test("rejects runtime sample counts outside the public one-or-two boundary", () => { + const contract = parseContract(passingRules); + const first = parseSample(); + const invalidCounts: readonly unknown[] = [[], [first, first, first]]; + + for (const samples of invalidCounts) { + expect(() => validateDirectNamedLayout( + contract, + samples as readonly [DirectNamedLayoutSample], + )).toThrow("requires exactly one or two parsed samples"); + } + }); + + test("checks static rules in both samples", () => { + const contract = parseContract([{ + id: "footer.visible", + kind: "not-clipped", + box: "footer", + tolerance: 0, + }]); + const first = parseSample(); + const second = parseSample(sampleInput({ + boxes: first.boxes.map((box) => box.name === "footer" + ? { ...box, y: 550 } + : box), + })); + const result = validateDirectNamedLayout(contract, [first, second]); + + expect(result.violations).toHaveLength(1); + expect(result.violations[0]).toMatchObject({ + code: "clipped", + ruleId: "footer.visible", + sample: "second", + }); + }); +}); diff --git a/src/tooling/browser-layout-contract.ts b/src/tooling/browser-layout-contract.ts new file mode 100644 index 0000000..51abf40 --- /dev/null +++ b/src/tooling/browser-layout-contract.ts @@ -0,0 +1,794 @@ +export const DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA = + "direct.named-layout-sample/v1" as const; +export const DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA = + "direct.named-layout-contract/v1" as const; + +const MAX_LAYOUT_BOXES = 128; +const MAX_LAYOUT_RULES = 256; +const MAX_LAYOUT_COORDINATE = 10_000_000; +const MAX_LAYOUT_TOLERANCE = 10_000; +const MAX_LAYOUT_NAME_LENGTH = 128; +const LAYOUT_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.:/-]*$/u; +const RESERVED_LAYOUT_NAMES = new Set([ + "__proto__", + "constructor", + "prototype", +]); + +const SAMPLE_KEYS = new Set(["boxes", "schema", "viewport"]); +const VIEWPORT_KEYS = new Set(["height", "width"]); +const BOX_KEYS = new Set(["height", "name", "width", "x", "y"]); +const CONTRACT_KEYS = new Set(["rules", "schema"]); +const INSIDE_RULE_KEYS = new Set([ + "id", + "inner", + "kind", + "outer", + "tolerance", +]); +const PAIR_RULE_KEYS = new Set([ + "first", + "id", + "kind", + "second", + "tolerance", +]); +const BOX_TOLERANCE_RULE_KEYS = new Set([ + "box", + "id", + "kind", + "tolerance", +]); +const MINIMUM_SIZE_RULE_KEYS = new Set([ + "box", + "id", + "kind", + "minimumHeight", + "minimumWidth", +]); + +export interface DirectNamedLayoutViewport { + readonly height: number; + readonly width: number; +} + +export interface DirectNamedLayoutBox { + readonly height: number; + readonly name: string; + readonly width: number; + readonly x: number; + readonly y: number; +} + +export interface DirectNamedLayoutSample { + readonly boxes: readonly DirectNamedLayoutBox[]; + readonly schema: typeof DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA; + readonly viewport: DirectNamedLayoutViewport; +} + +export interface DirectNamedLayoutInsideRule { + readonly id: string; + readonly inner: string; + readonly kind: "inside"; + readonly outer: string; + readonly tolerance: number; +} + +export interface DirectNamedLayoutNoOverlapRule { + readonly first: string; + readonly id: string; + readonly kind: "no-overlap"; + readonly second: string; + readonly tolerance: number; +} + +export interface DirectNamedLayoutCenterRule { + readonly first: string; + readonly id: string; + readonly kind: "center-x" | "center-y"; + readonly second: string; + readonly tolerance: number; +} + +export interface DirectNamedLayoutNotClippedRule { + readonly box: string; + readonly id: string; + readonly kind: "not-clipped"; + readonly tolerance: number; +} + +export interface DirectNamedLayoutMinimumSizeRule { + readonly box: string; + readonly id: string; + readonly kind: "minimum-size"; + readonly minimumHeight: number; + readonly minimumWidth: number; +} + +export interface DirectNamedLayoutStableRule { + readonly box: string; + readonly id: string; + readonly kind: "stable"; + readonly tolerance: number; +} + +export type DirectNamedLayoutRule = + | DirectNamedLayoutInsideRule + | DirectNamedLayoutNoOverlapRule + | DirectNamedLayoutCenterRule + | DirectNamedLayoutNotClippedRule + | DirectNamedLayoutMinimumSizeRule + | DirectNamedLayoutStableRule; + +export interface DirectNamedLayoutContract { + readonly rules: readonly DirectNamedLayoutRule[]; + readonly schema: typeof DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA; +} + +export interface DirectNamedLayoutParseError { + readonly code: "invalid-contract" | "invalid-sample"; + readonly message: string; +} + +export type DirectNamedLayoutParseResult = + | Readonly<{ readonly ok: true; readonly value: Value }> + | Readonly<{ + readonly error: DirectNamedLayoutParseError; + readonly ok: false; + }>; + +export type DirectNamedLayoutViolationCode = + | "clipped" + | "misaligned" + | "missing-box" + | "outside" + | "overlap" + | "second-sample-required" + | "too-small" + | "unstable" + | "viewport-changed"; + +export interface DirectNamedLayoutViolation { + readonly code: DirectNamedLayoutViolationCode; + readonly message: string; + readonly ruleId: string; + readonly ruleKind: DirectNamedLayoutRule["kind"]; + readonly sample: "first" | "pair" | "second"; +} + +export interface DirectNamedLayoutValidation { + readonly ok: boolean; + readonly violations: readonly DirectNamedLayoutViolation[]; +} + +class NamedLayoutInputError extends Error {} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function requireExactRecord( + value: unknown, + expected: ReadonlySet, + label: string, +): Readonly> { + if (!isRecord(value)) { + throw new NamedLayoutInputError(`${label} must be an object`); + } + const keys = Object.keys(value); + if ( + keys.length !== expected.size + || keys.some((key) => !expected.has(key)) + || [...expected].some((key) => !Object.hasOwn(value, key)) + ) { + throw new NamedLayoutInputError( + `${label} must contain exactly: ${[...expected].sort().join(", ")}`, + ); + } + return value; +} + +function requireLayoutName(value: unknown, label: string): string { + if ( + typeof value !== "string" + || value.length > MAX_LAYOUT_NAME_LENGTH + || !LAYOUT_NAME_PATTERN.test(value) + || RESERVED_LAYOUT_NAMES.has(value) + ) { + throw new NamedLayoutInputError( + `${label} must be a safe, unreserved 1-${String(MAX_LAYOUT_NAME_LENGTH)} character identifier`, + ); + } + return value; +} + +function requireBoundedNumber(options: { + readonly label: string; + readonly maximum: number; + readonly minimum: number; + readonly value: unknown; +}): number { + if ( + typeof options.value !== "number" + || !Number.isFinite(options.value) + || options.value < options.minimum + || options.value > options.maximum + ) { + throw new NamedLayoutInputError( + `${options.label} must be a finite number between ${String(options.minimum)} and ${String(options.maximum)}`, + ); + } + return options.value; +} + +function requireCoordinate(value: unknown, label: string): number { + return requireBoundedNumber({ + label, + maximum: MAX_LAYOUT_COORDINATE, + minimum: -MAX_LAYOUT_COORDINATE, + value, + }); +} + +function requireSize(value: unknown, label: string, positive: boolean): number { + const size = requireBoundedNumber({ + label, + maximum: MAX_LAYOUT_COORDINATE, + minimum: 0, + value, + }); + if (positive && size === 0) { + throw new NamedLayoutInputError(`${label} must be greater than zero`); + } + return size; +} + +function requireTolerance(value: unknown, label: string): number { + return requireBoundedNumber({ + label, + maximum: MAX_LAYOUT_TOLERANCE, + minimum: 0, + value, + }); +} + +function parseLayoutBox(input: unknown, index: number): DirectNamedLayoutBox { + const label = `Direct named layout box ${String(index)}`; + const record = requireExactRecord(input, BOX_KEYS, label); + return Object.freeze({ + height: requireSize(record.height, `${label} height`, true), + name: requireLayoutName(record.name, `${label} name`), + width: requireSize(record.width, `${label} width`, true), + x: requireCoordinate(record.x, `${label} x`), + y: requireCoordinate(record.y, `${label} y`), + }); +} + +function parseLayoutSampleUnchecked(input: unknown): DirectNamedLayoutSample { + const record = requireExactRecord( + input, + SAMPLE_KEYS, + "Direct named layout sample", + ); + if (record.schema !== DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA) { + throw new NamedLayoutInputError( + `Direct named layout sample schema must be ${DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA}`, + ); + } + const viewportRecord = requireExactRecord( + record.viewport, + VIEWPORT_KEYS, + "Direct named layout viewport", + ); + if (!Array.isArray(record.boxes)) { + throw new NamedLayoutInputError("Direct named layout boxes must be an array"); + } + if (record.boxes.length === 0 || record.boxes.length > MAX_LAYOUT_BOXES) { + throw new NamedLayoutInputError( + `Direct named layout samples require 1-${String(MAX_LAYOUT_BOXES)} boxes`, + ); + } + const boxes: DirectNamedLayoutBox[] = []; + for (let index = 0; index < record.boxes.length; index += 1) { + boxes.push(parseLayoutBox(record.boxes[index], index)); + } + const names = new Set(); + for (const box of boxes) { + if (names.has(box.name)) { + throw new NamedLayoutInputError( + `Direct named layout box name is duplicated: ${box.name}`, + ); + } + names.add(box.name); + } + return Object.freeze({ + boxes: Object.freeze(boxes), + schema: DIRECT_NAMED_LAYOUT_SAMPLE_SCHEMA, + viewport: Object.freeze({ + height: requireSize( + viewportRecord.height, + "Direct named layout viewport height", + true, + ), + width: requireSize( + viewportRecord.width, + "Direct named layout viewport width", + true, + ), + }), + }); +} + +function parsePairRule( + record: Readonly>, + kind: DirectNamedLayoutNoOverlapRule["kind"] | DirectNamedLayoutCenterRule["kind"], + id: string, +): DirectNamedLayoutNoOverlapRule | DirectNamedLayoutCenterRule { + requireExactRecord(record, PAIR_RULE_KEYS, `Direct named layout ${kind} rule`); + const first = requireLayoutName( + record.first, + `Direct named layout rule ${id} first box`, + ); + const second = requireLayoutName( + record.second, + `Direct named layout rule ${id} second box`, + ); + if (first === second) { + throw new NamedLayoutInputError( + `Direct named layout rule ${id} must name two different boxes`, + ); + } + return Object.freeze({ + first, + id, + kind, + second, + tolerance: requireTolerance( + record.tolerance, + `Direct named layout rule ${id} tolerance`, + ), + }); +} + +function parseBoxToleranceRule( + record: Readonly>, + kind: DirectNamedLayoutNotClippedRule["kind"] | DirectNamedLayoutStableRule["kind"], + id: string, +): DirectNamedLayoutNotClippedRule | DirectNamedLayoutStableRule { + requireExactRecord( + record, + BOX_TOLERANCE_RULE_KEYS, + `Direct named layout ${kind} rule`, + ); + return Object.freeze({ + box: requireLayoutName(record.box, `Direct named layout rule ${id} box`), + id, + kind, + tolerance: requireTolerance( + record.tolerance, + `Direct named layout rule ${id} tolerance`, + ), + }); +} + +function parseLayoutRule(input: unknown, index: number): DirectNamedLayoutRule { + const label = `Direct named layout rule ${String(index)}`; + if (!isRecord(input)) { + throw new NamedLayoutInputError(`${label} must be an object`); + } + const id = requireLayoutName(input.id, `${label} id`); + switch (input.kind) { + case "inside": { + const record = requireExactRecord(input, INSIDE_RULE_KEYS, `${label} inside`); + const inner = requireLayoutName(record.inner, `${label} inner box`); + const outer = requireLayoutName(record.outer, `${label} outer box`); + if (inner === outer) { + throw new NamedLayoutInputError( + `${label} must name different inner and outer boxes`, + ); + } + return Object.freeze({ + id, + inner, + kind: "inside", + outer, + tolerance: requireTolerance(record.tolerance, `${label} tolerance`), + }); + } + case "no-overlap": + case "center-x": + case "center-y": + return parsePairRule(input, input.kind, id); + case "not-clipped": + case "stable": + return parseBoxToleranceRule(input, input.kind, id); + case "minimum-size": { + const record = requireExactRecord( + input, + MINIMUM_SIZE_RULE_KEYS, + `${label} minimum-size`, + ); + const minimumHeight = requireSize( + record.minimumHeight, + `${label} minimumHeight`, + false, + ); + const minimumWidth = requireSize( + record.minimumWidth, + `${label} minimumWidth`, + false, + ); + if (minimumHeight === 0 && minimumWidth === 0) { + throw new NamedLayoutInputError( + `${label} must require a positive width or height`, + ); + } + return Object.freeze({ + box: requireLayoutName(record.box, `${label} box`), + id, + kind: "minimum-size", + minimumHeight, + minimumWidth, + }); + } + default: + throw new NamedLayoutInputError( + `${label} kind must be inside, no-overlap, center-x, center-y, not-clipped, minimum-size, or stable`, + ); + } +} + +function parseLayoutContractUnchecked(input: unknown): DirectNamedLayoutContract { + const record = requireExactRecord( + input, + CONTRACT_KEYS, + "Direct named layout contract", + ); + if (record.schema !== DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA) { + throw new NamedLayoutInputError( + `Direct named layout contract schema must be ${DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA}`, + ); + } + if (!Array.isArray(record.rules)) { + throw new NamedLayoutInputError("Direct named layout rules must be an array"); + } + if (record.rules.length === 0 || record.rules.length > MAX_LAYOUT_RULES) { + throw new NamedLayoutInputError( + `Direct named layout contracts require 1-${String(MAX_LAYOUT_RULES)} rules`, + ); + } + const rules: DirectNamedLayoutRule[] = []; + for (let index = 0; index < record.rules.length; index += 1) { + rules.push(parseLayoutRule(record.rules[index], index)); + } + const ids = new Set(); + for (const rule of rules) { + if (ids.has(rule.id)) { + throw new NamedLayoutInputError( + `Direct named layout rule id is duplicated: ${rule.id}`, + ); + } + ids.add(rule.id); + } + return Object.freeze({ + rules: Object.freeze(rules), + schema: DIRECT_NAMED_LAYOUT_CONTRACT_SCHEMA, + }); +} + +function parseError( + code: DirectNamedLayoutParseError["code"], + error: unknown, +): DirectNamedLayoutParseError { + return Object.freeze({ + code, + message: error instanceof NamedLayoutInputError + ? error.message + : `Direct named layout ${code === "invalid-sample" ? "sample" : "contract"} could not be read`, + }); +} + +/** Parse one driver-produced, versioned named-box sample from `unknown`. */ +export function parseDirectNamedLayoutSample( + input: unknown, +): DirectNamedLayoutParseResult { + try { + return Object.freeze({ ok: true, value: parseLayoutSampleUnchecked(input) }); + } catch (error: unknown) { + return Object.freeze({ + error: parseError("invalid-sample", error), + ok: false, + }); + } +} + +/** Parse one versioned set of product-owned named-box rules from `unknown`. */ +export function parseDirectNamedLayoutContract( + input: unknown, +): DirectNamedLayoutParseResult { + try { + return Object.freeze({ ok: true, value: parseLayoutContractUnchecked(input) }); + } catch (error: unknown) { + return Object.freeze({ + error: parseError("invalid-contract", error), + ok: false, + }); + } +} + +function boxMap(sample: DirectNamedLayoutSample): ReadonlyMap { + return new Map(sample.boxes.map((box) => [box.name, box])); +} + +function right(box: DirectNamedLayoutBox): number { + return box.x + box.width; +} + +function bottom(box: DirectNamedLayoutBox): number { + return box.y + box.height; +} + +function violation( + options: DirectNamedLayoutViolation, +): DirectNamedLayoutViolation { + return Object.freeze(options); +} + +function missingBoxViolation( + rule: DirectNamedLayoutRule, + sample: DirectNamedLayoutViolation["sample"], + names: readonly string[], +): DirectNamedLayoutViolation { + return violation({ + code: "missing-box", + message: `Rule ${rule.id} references missing box${names.length === 1 ? "" : "es"}: ${names.join(", ")}`, + ruleId: rule.id, + ruleKind: rule.kind, + sample, + }); +} + +function pairBoxes( + rule: DirectNamedLayoutNoOverlapRule | DirectNamedLayoutCenterRule, + boxes: ReadonlyMap, +): readonly [DirectNamedLayoutBox, DirectNamedLayoutBox] | null { + const first = boxes.get(rule.first); + const second = boxes.get(rule.second); + return first === undefined || second === undefined ? null : [first, second]; +} + +function validateStaticRule( + rule: Exclude, + sampleName: "first" | "second", + sample: DirectNamedLayoutSample, + boxes: ReadonlyMap, +): DirectNamedLayoutViolation | null { + switch (rule.kind) { + case "inside": { + const inner = boxes.get(rule.inner); + const outer = boxes.get(rule.outer); + if (inner === undefined || outer === undefined) { + return missingBoxViolation( + rule, + sampleName, + [inner === undefined ? rule.inner : null, outer === undefined ? rule.outer : null] + .filter((name): name is string => name !== null), + ); + } + if ( + inner.x < outer.x - rule.tolerance + || inner.y < outer.y - rule.tolerance + || right(inner) > right(outer) + rule.tolerance + || bottom(inner) > bottom(outer) + rule.tolerance + ) { + return violation({ + code: "outside", + message: `Box ${rule.inner} is not inside ${rule.outer} within ${String(rule.tolerance)} CSS pixels`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: sampleName, + }); + } + return null; + } + case "no-overlap": { + const pair = pairBoxes(rule, boxes); + if (pair === null) { + return missingBoxViolation( + rule, + sampleName, + [rule.first, rule.second].filter((name) => !boxes.has(name)), + ); + } + const [first, second] = pair; + const overlapWidth = Math.min(right(first), right(second)) + - Math.max(first.x, second.x); + const overlapHeight = Math.min(bottom(first), bottom(second)) + - Math.max(first.y, second.y); + if (overlapWidth > rule.tolerance && overlapHeight > rule.tolerance) { + return violation({ + code: "overlap", + message: `Boxes ${rule.first} and ${rule.second} overlap beyond ${String(rule.tolerance)} CSS pixels`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: sampleName, + }); + } + return null; + } + case "center-x": + case "center-y": { + const pair = pairBoxes(rule, boxes); + if (pair === null) { + return missingBoxViolation( + rule, + sampleName, + [rule.first, rule.second].filter((name) => !boxes.has(name)), + ); + } + const [first, second] = pair; + const firstCenter = rule.kind === "center-x" + ? first.x + first.width / 2 + : first.y + first.height / 2; + const secondCenter = rule.kind === "center-x" + ? second.x + second.width / 2 + : second.y + second.height / 2; + if (Math.abs(firstCenter - secondCenter) > rule.tolerance) { + return violation({ + code: "misaligned", + message: `Boxes ${rule.first} and ${rule.second} are not ${rule.kind} aligned within ${String(rule.tolerance)} CSS pixels`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: sampleName, + }); + } + return null; + } + case "not-clipped": { + const box = boxes.get(rule.box); + if (box === undefined) { + return missingBoxViolation(rule, sampleName, [rule.box]); + } + if ( + box.x < -rule.tolerance + || box.y < -rule.tolerance + || right(box) > sample.viewport.width + rule.tolerance + || bottom(box) > sample.viewport.height + rule.tolerance + ) { + return violation({ + code: "clipped", + message: `Box ${rule.box} extends outside the viewport beyond ${String(rule.tolerance)} CSS pixels`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: sampleName, + }); + } + return null; + } + case "minimum-size": { + const box = boxes.get(rule.box); + if (box === undefined) { + return missingBoxViolation(rule, sampleName, [rule.box]); + } + if (box.width < rule.minimumWidth || box.height < rule.minimumHeight) { + return violation({ + code: "too-small", + message: `Box ${rule.box} is smaller than ${String(rule.minimumWidth)} by ${String(rule.minimumHeight)} CSS pixels`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: sampleName, + }); + } + return null; + } + } +} + +function validateStabilityRule( + rule: DirectNamedLayoutStableRule, + firstSample: DirectNamedLayoutSample, + secondSample: DirectNamedLayoutSample | undefined, + first: ReadonlyMap, + second: ReadonlyMap | null, +): DirectNamedLayoutViolation | null { + if (secondSample === undefined || second === null) { + return violation({ + code: "second-sample-required", + message: `Rule ${rule.id} requires two layout samples`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: "pair", + }); + } + if ( + firstSample.viewport.width !== secondSample.viewport.width + || firstSample.viewport.height !== secondSample.viewport.height + ) { + return violation({ + code: "viewport-changed", + message: `Rule ${rule.id} requires two samples at the same viewport`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: "pair", + }); + } + const firstBox = first.get(rule.box); + const secondBox = second.get(rule.box); + if (firstBox === undefined || secondBox === undefined) { + return missingBoxViolation( + rule, + "pair", + [firstBox === undefined ? `first:${rule.box}` : null, secondBox === undefined ? `second:${rule.box}` : null] + .filter((name): name is string => name !== null), + ); + } + if ( + Math.abs(firstBox.x - secondBox.x) > rule.tolerance + || Math.abs(firstBox.y - secondBox.y) > rule.tolerance + || Math.abs(firstBox.width - secondBox.width) > rule.tolerance + || Math.abs(firstBox.height - secondBox.height) > rule.tolerance + ) { + return violation({ + code: "unstable", + message: `Box ${rule.box} changed between samples beyond ${String(rule.tolerance)} CSS pixels`, + ruleId: rule.id, + ruleKind: rule.kind, + sample: "pair", + }); + } + return null; +} + +/** + * Validate only the named product rules. Static rules run against every + * supplied sample; `stable` compares the first and second samples. + */ +export function validateDirectNamedLayout( + contract: DirectNamedLayoutContract, + samples: + | readonly [DirectNamedLayoutSample] + | readonly [DirectNamedLayoutSample, DirectNamedLayoutSample], +): DirectNamedLayoutValidation { + if (!Array.isArray(samples) || (samples.length !== 1 && samples.length !== 2)) { + throw new RangeError( + "Direct named layout validation requires exactly one or two parsed samples", + ); + } + const violations: DirectNamedLayoutViolation[] = []; + const firstBoxes = boxMap(samples[0]); + const secondSample = samples[1]; + const secondBoxes = secondSample === undefined ? null : boxMap(secondSample); + for (const rule of contract.rules) { + if (rule.kind === "stable") { + const found = validateStabilityRule( + rule, + samples[0], + secondSample, + firstBoxes, + secondBoxes, + ); + if (found !== null) violations.push(found); + continue; + } + const firstViolation = validateStaticRule( + rule, + "first", + samples[0], + firstBoxes, + ); + if (firstViolation !== null) violations.push(firstViolation); + if (secondSample !== undefined && secondBoxes !== null) { + const secondViolation = validateStaticRule( + rule, + "second", + secondSample, + secondBoxes, + ); + if (secondViolation !== null) violations.push(secondViolation); + } + } + return Object.freeze({ + ok: violations.length === 0, + violations: Object.freeze(violations), + }); +} diff --git a/src/tooling/browser-verification-entry.ts b/src/tooling/browser-verification-entry.ts index ad5420e..12a962b 100644 --- a/src/tooling/browser-verification-entry.ts +++ b/src/tooling/browser-verification-entry.ts @@ -12,6 +12,7 @@ import { } from "./browser-verification.js"; export * from "./browser-verification.js"; +export * from "./browser-layout-contract.js"; export type DirectSessionBrowserContract = DirectBrowserContract< DirectSessionManifest,