diff --git a/.agent/skills/impeccable/SKILL.md b/.agent/skills/impeccable/SKILL.md index 63e309e81..dbb3b81d1 100644 --- a/.agent/skills/impeccable/SKILL.md +++ b/.agent/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 license: Apache 2.0 allowed-tools: - Bash(npx impeccable *) diff --git a/.agent/skills/impeccable/reference/component-review.md b/.agent/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..a593b6f97 --- /dev/null +++ b/.agent/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.agent/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.agent/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.agent/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.agent/skills/impeccable/reference/degraded/asset-producer.md b/.agent/skills/impeccable/reference/degraded/asset-producer.md index 40cb05c5e..df07f9c28 100644 --- a/.agent/skills/impeccable/reference/degraded/asset-producer.md +++ b/.agent/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.agent/skills/impeccable/reference/degraded/finish-reviewer.md b/.agent/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.agent/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.agent/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.agent/skills/impeccable/reference/new-work.md b/.agent/skills/impeccable/reference/new-work.md index 40c0e1f68..48118d0fd 100644 --- a/.agent/skills/impeccable/reference/new-work.md +++ b/.agent/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.agent/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.agent/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.agent/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.agent/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.agent/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.agent/skills/impeccable/scripts/VERSION b/.agent/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.agent/skills/impeccable/scripts/VERSION +++ b/.agent/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.agents/skills/impeccable/SKILL.md b/.agents/skills/impeccable/SKILL.md index f000bc443..4476bc041 100644 --- a/.agents/skills/impeccable/SKILL.md +++ b/.agents/skills/impeccable/SKILL.md @@ -2,7 +2,7 @@ name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. metadata: - version: 4.3.1 + version: 4.4.0 --- This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft. diff --git a/.agents/skills/impeccable/agents/impeccable_asset_producer.toml b/.agents/skills/impeccable/agents/impeccable_asset_producer.toml index 30f49ab65..6a76b6539 100644 --- a/.agents/skills/impeccable/agents/impeccable_asset_producer.toml +++ b/.agents/skills/impeccable/agents/impeccable_asset_producer.toml @@ -15,6 +15,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.agents/skills/impeccable/agents/impeccable_finish_reviewer.toml b/.agents/skills/impeccable/agents/impeccable_finish_reviewer.toml index cdecfe4dc..a6e04cdd6 100644 --- a/.agents/skills/impeccable/agents/impeccable_finish_reviewer.toml +++ b/.agents/skills/impeccable/agents/impeccable_finish_reviewer.toml @@ -18,7 +18,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.agents/skills/impeccable/reference/component-review.md b/.agents/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..66e757101 --- /dev/null +++ b/.agents/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.agents/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.agents/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.agents/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.agents/skills/impeccable/reference/degraded/asset-producer.md b/.agents/skills/impeccable/reference/degraded/asset-producer.md index 903b97776..33fc101eb 100644 --- a/.agents/skills/impeccable/reference/degraded/asset-producer.md +++ b/.agents/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.agents/skills/impeccable/reference/degraded/finish-reviewer.md b/.agents/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.agents/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.agents/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.agents/skills/impeccable/reference/new-work.md b/.agents/skills/impeccable/reference/new-work.md index cd2a22b98..1b0e8e865 100644 --- a/.agents/skills/impeccable/reference/new-work.md +++ b/.agents/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.agents/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.agents/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.agents/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.agents/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.agents/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.agents/skills/impeccable/scripts/VERSION b/.agents/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.agents/skills/impeccable/scripts/VERSION +++ b/.agents/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cbac542e3..f3f49e0d3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ { "name": "impeccable", "description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", - "version": "4.3.1", + "version": "4.4.0", "author": { "name": "Paul Bakaus", "email": "paul@paulbakaus.com" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 565c74376..b40ceed2b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "impeccable", "description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", - "version": "4.3.1", + "version": "4.4.0", "author": { "name": "Paul Bakaus", "email": "paul@paulbakaus.com" diff --git a/.claude/agents/impeccable-asset-producer.md b/.claude/agents/impeccable-asset-producer.md index 0ef2b3cda..544b80915 100644 --- a/.claude/agents/impeccable-asset-producer.md +++ b/.claude/agents/impeccable-asset-producer.md @@ -18,6 +18,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.claude/agents/impeccable-finish-reviewer.md b/.claude/agents/impeccable-finish-reviewer.md index f83809325..74c372e23 100644 --- a/.claude/agents/impeccable-finish-reviewer.md +++ b/.claude/agents/impeccable-finish-reviewer.md @@ -21,7 +21,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.claude/settings.json b/.claude/settings.json index fee80622d..77eb8729f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,6 +1,18 @@ { "description": "Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/impeccable\" ] || \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/impeccable\" hook", + "timeout": 5, + "statusMessage": "Preparing build session" + } + ] + } + ], "PostToolUse": [ { "matcher": "Edit|Write", diff --git a/.claude/skills/impeccable/SKILL.md b/.claude/skills/impeccable/SKILL.md index 6114c462b..d01041b5d 100644 --- a/.claude/skills/impeccable/SKILL.md +++ b/.claude/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 user-invocable: true argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 diff --git a/.claude/skills/impeccable/reference/component-review.md b/.claude/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..fb271b1fa --- /dev/null +++ b/.claude/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.claude/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.claude/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.claude/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.claude/skills/impeccable/reference/degraded/asset-producer.md b/.claude/skills/impeccable/reference/degraded/asset-producer.md index 2319986df..66e71130e 100644 --- a/.claude/skills/impeccable/reference/degraded/asset-producer.md +++ b/.claude/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.claude/skills/impeccable/reference/degraded/finish-reviewer.md b/.claude/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.claude/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.claude/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.claude/skills/impeccable/reference/new-work.md b/.claude/skills/impeccable/reference/new-work.md index dc060afd2..332a55764 100644 --- a/.claude/skills/impeccable/reference/new-work.md +++ b/.claude/skills/impeccable/reference/new-work.md @@ -98,7 +98,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.claude/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.claude/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.claude/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.claude/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -107,7 +107,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -145,3 +148,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.claude/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.claude/skills/impeccable/scripts/VERSION b/.claude/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.claude/skills/impeccable/scripts/VERSION +++ b/.claude/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.cursor/agents/impeccable-asset-producer.md b/.cursor/agents/impeccable-asset-producer.md index 005c61c69..8e81f7473 100644 --- a/.cursor/agents/impeccable-asset-producer.md +++ b/.cursor/agents/impeccable-asset-producer.md @@ -16,6 +16,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.cursor/agents/impeccable-finish-reviewer.md b/.cursor/agents/impeccable-finish-reviewer.md index ee4ec7f8a..669227caf 100644 --- a/.cursor/agents/impeccable-finish-reviewer.md +++ b/.cursor/agents/impeccable-finish-reviewer.md @@ -20,7 +20,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.cursor/skills/impeccable/SKILL.md b/.cursor/skills/impeccable/SKILL.md index c1a2e8369..5020b9948 100644 --- a/.cursor/skills/impeccable/SKILL.md +++ b/.cursor/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 license: Apache 2.0 --- diff --git a/.cursor/skills/impeccable/reference/component-review.md b/.cursor/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..2f0dc22a8 --- /dev/null +++ b/.cursor/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.cursor/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.cursor/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.cursor/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.cursor/skills/impeccable/reference/degraded/asset-producer.md b/.cursor/skills/impeccable/reference/degraded/asset-producer.md index a11b6e4ce..6340454fb 100644 --- a/.cursor/skills/impeccable/reference/degraded/asset-producer.md +++ b/.cursor/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.cursor/skills/impeccable/reference/degraded/finish-reviewer.md b/.cursor/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.cursor/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.cursor/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.cursor/skills/impeccable/reference/new-work.md b/.cursor/skills/impeccable/reference/new-work.md index a67c48a63..0edd4d772 100644 --- a/.cursor/skills/impeccable/reference/new-work.md +++ b/.cursor/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.cursor/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.cursor/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.cursor/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.cursor/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.cursor/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.cursor/skills/impeccable/scripts/VERSION b/.cursor/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.cursor/skills/impeccable/scripts/VERSION +++ b/.cursor/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.dsh/skills/impeccable/SKILL.md b/.dsh/skills/impeccable/SKILL.md index 48e9ad5e3..46613d4a3 100644 --- a/.dsh/skills/impeccable/SKILL.md +++ b/.dsh/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 user-invocable: true license: Apache 2.0 --- diff --git a/.dsh/skills/impeccable/reference/component-review.md b/.dsh/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..cdbb7e6d0 --- /dev/null +++ b/.dsh/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.dsh/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.dsh/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.dsh/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.dsh/skills/impeccable/reference/degraded/asset-producer.md b/.dsh/skills/impeccable/reference/degraded/asset-producer.md index 6772fffff..7707eefa9 100644 --- a/.dsh/skills/impeccable/reference/degraded/asset-producer.md +++ b/.dsh/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.dsh/skills/impeccable/reference/degraded/finish-reviewer.md b/.dsh/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.dsh/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.dsh/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.dsh/skills/impeccable/reference/new-work.md b/.dsh/skills/impeccable/reference/new-work.md index 427619a86..7a45338c3 100644 --- a/.dsh/skills/impeccable/reference/new-work.md +++ b/.dsh/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.dsh/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.dsh/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.dsh/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.dsh/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.dsh/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.dsh/skills/impeccable/scripts/VERSION b/.dsh/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.dsh/skills/impeccable/scripts/VERSION +++ b/.dsh/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 000000000..d8df5a28d --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,29 @@ +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "^run_shell_command$", + "hooks": [ + { + "name": "impeccable-session", + "type": "command", + "command": "[ ! -f \"$GEMINI_PROJECT_DIR/.gemini/skills/impeccable/scripts/impeccable\" ] || \"$GEMINI_PROJECT_DIR/.gemini/skills/impeccable/scripts/impeccable\" hook", + "timeout": 5000 + } + ] + } + ], + "AfterAgent": [ + { + "hooks": [ + { + "name": "impeccable-completion", + "type": "command", + "command": "[ ! -f \"$GEMINI_PROJECT_DIR/.gemini/skills/impeccable/scripts/impeccable\" ] || \"$GEMINI_PROJECT_DIR/.gemini/skills/impeccable/scripts/impeccable\" hook", + "timeout": 30000 + } + ] + } + ] + } +} diff --git a/.gemini/skills/impeccable/SKILL.md b/.gemini/skills/impeccable/SKILL.md index 42b728cc6..f134d964c 100644 --- a/.gemini/skills/impeccable/SKILL.md +++ b/.gemini/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 --- This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft. diff --git a/.gemini/skills/impeccable/reference/component-review.md b/.gemini/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..b546ce277 --- /dev/null +++ b/.gemini/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.gemini/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.gemini/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.gemini/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.gemini/skills/impeccable/reference/degraded/asset-producer.md b/.gemini/skills/impeccable/reference/degraded/asset-producer.md index bfc02753d..dd27f15f8 100644 --- a/.gemini/skills/impeccable/reference/degraded/asset-producer.md +++ b/.gemini/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.gemini/skills/impeccable/reference/degraded/finish-reviewer.md b/.gemini/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.gemini/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.gemini/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.gemini/skills/impeccable/reference/new-work.md b/.gemini/skills/impeccable/reference/new-work.md index f2cea8450..ad7c06523 100644 --- a/.gemini/skills/impeccable/reference/new-work.md +++ b/.gemini/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.gemini/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.gemini/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.gemini/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.gemini/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.gemini/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.gemini/skills/impeccable/scripts/VERSION b/.gemini/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.gemini/skills/impeccable/scripts/VERSION +++ b/.gemini/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.github/agents/impeccable-asset-producer.agent.md b/.github/agents/impeccable-asset-producer.agent.md index 3641e9f79..c14d06d1d 100644 --- a/.github/agents/impeccable-asset-producer.agent.md +++ b/.github/agents/impeccable-asset-producer.agent.md @@ -14,6 +14,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.github/agents/impeccable-finish-reviewer.agent.md b/.github/agents/impeccable-finish-reviewer.agent.md index 04e5102e9..dfb0e6919 100644 --- a/.github/agents/impeccable-finish-reviewer.agent.md +++ b/.github/agents/impeccable-finish-reviewer.agent.md @@ -17,7 +17,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.github/skills/impeccable/SKILL.md b/.github/skills/impeccable/SKILL.md index f0d333470..c75103aa1 100644 --- a/.github/skills/impeccable/SKILL.md +++ b/.github/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 user-invocable: true argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 diff --git a/.github/skills/impeccable/reference/component-review.md b/.github/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..28a49b2ba --- /dev/null +++ b/.github/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.github/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.github/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.github/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.github/skills/impeccable/reference/degraded/asset-producer.md b/.github/skills/impeccable/reference/degraded/asset-producer.md index 71dd859fa..2f1e69d39 100644 --- a/.github/skills/impeccable/reference/degraded/asset-producer.md +++ b/.github/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.github/skills/impeccable/reference/degraded/finish-reviewer.md b/.github/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.github/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.github/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.github/skills/impeccable/reference/new-work.md b/.github/skills/impeccable/reference/new-work.md index 229417a96..24d759488 100644 --- a/.github/skills/impeccable/reference/new-work.md +++ b/.github/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.github/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.github/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.github/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.github/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.github/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.github/skills/impeccable/scripts/VERSION b/.github/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.github/skills/impeccable/scripts/VERSION +++ b/.github/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.grok/agents/impeccable-asset-producer.md b/.grok/agents/impeccable-asset-producer.md index ed11c86c0..42c32fa0b 100644 --- a/.grok/agents/impeccable-asset-producer.md +++ b/.grok/agents/impeccable-asset-producer.md @@ -18,6 +18,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.grok/agents/impeccable-finish-reviewer.md b/.grok/agents/impeccable-finish-reviewer.md index f83809325..74c372e23 100644 --- a/.grok/agents/impeccable-finish-reviewer.md +++ b/.grok/agents/impeccable-finish-reviewer.md @@ -21,7 +21,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.grok/skills/impeccable/SKILL.md b/.grok/skills/impeccable/SKILL.md index 100bcd5fd..e0c4f4f2f 100644 --- a/.grok/skills/impeccable/SKILL.md +++ b/.grok/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 user-invocable: true argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 diff --git a/.grok/skills/impeccable/reference/component-review.md b/.grok/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..c26fe0a45 --- /dev/null +++ b/.grok/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.grok/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.grok/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.grok/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.grok/skills/impeccable/reference/degraded/asset-producer.md b/.grok/skills/impeccable/reference/degraded/asset-producer.md index 9428f5198..13b56ab8d 100644 --- a/.grok/skills/impeccable/reference/degraded/asset-producer.md +++ b/.grok/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.grok/skills/impeccable/reference/degraded/finish-reviewer.md b/.grok/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.grok/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.grok/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.grok/skills/impeccable/reference/new-work.md b/.grok/skills/impeccable/reference/new-work.md index 6ca7d2f66..74f7a40fb 100644 --- a/.grok/skills/impeccable/reference/new-work.md +++ b/.grok/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.grok/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.grok/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.grok/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.grok/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.grok/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.grok/skills/impeccable/scripts/VERSION b/.grok/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.grok/skills/impeccable/scripts/VERSION +++ b/.grok/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.hermes/skills/impeccable/SKILL.md b/.hermes/skills/impeccable/SKILL.md index 37d40b249..b1deca009 100644 --- a/.hermes/skills/impeccable/SKILL.md +++ b/.hermes/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 license: Apache 2.0 --- diff --git a/.hermes/skills/impeccable/reference/component-review.md b/.hermes/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..97000a120 --- /dev/null +++ b/.hermes/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.hermes/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.hermes/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.hermes/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.hermes/skills/impeccable/reference/degraded/asset-producer.md b/.hermes/skills/impeccable/reference/degraded/asset-producer.md index 78a2ebdc2..e202ae79c 100644 --- a/.hermes/skills/impeccable/reference/degraded/asset-producer.md +++ b/.hermes/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.hermes/skills/impeccable/reference/degraded/finish-reviewer.md b/.hermes/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.hermes/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.hermes/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.hermes/skills/impeccable/reference/new-work.md b/.hermes/skills/impeccable/reference/new-work.md index dafa32542..dfee12ed7 100644 --- a/.hermes/skills/impeccable/reference/new-work.md +++ b/.hermes/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.hermes/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.hermes/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.hermes/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.hermes/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.hermes/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.hermes/skills/impeccable/scripts/VERSION b/.hermes/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.hermes/skills/impeccable/scripts/VERSION +++ b/.hermes/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.kiro/skills/impeccable/SKILL.md b/.kiro/skills/impeccable/SKILL.md index 3a601884e..4fff4f8f8 100644 --- a/.kiro/skills/impeccable/SKILL.md +++ b/.kiro/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 license: Apache 2.0 --- diff --git a/.kiro/skills/impeccable/reference/component-review.md b/.kiro/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..aedc9ec69 --- /dev/null +++ b/.kiro/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.kiro/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.kiro/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.kiro/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.kiro/skills/impeccable/reference/degraded/asset-producer.md b/.kiro/skills/impeccable/reference/degraded/asset-producer.md index c1911793c..6198894e9 100644 --- a/.kiro/skills/impeccable/reference/degraded/asset-producer.md +++ b/.kiro/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.kiro/skills/impeccable/reference/degraded/finish-reviewer.md b/.kiro/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.kiro/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.kiro/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.kiro/skills/impeccable/reference/new-work.md b/.kiro/skills/impeccable/reference/new-work.md index a963f1fba..97c25a967 100644 --- a/.kiro/skills/impeccable/reference/new-work.md +++ b/.kiro/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.kiro/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.kiro/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.kiro/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.kiro/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.kiro/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.kiro/skills/impeccable/scripts/VERSION b/.kiro/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.kiro/skills/impeccable/scripts/VERSION +++ b/.kiro/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.opencode/skills/impeccable/SKILL.md b/.opencode/skills/impeccable/SKILL.md index c31bc51f5..9f62a802a 100644 --- a/.opencode/skills/impeccable/SKILL.md +++ b/.opencode/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 user-invocable: true argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 diff --git a/.opencode/skills/impeccable/reference/component-review.md b/.opencode/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..62775054e --- /dev/null +++ b/.opencode/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.opencode/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.opencode/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.opencode/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.opencode/skills/impeccable/reference/degraded/asset-producer.md b/.opencode/skills/impeccable/reference/degraded/asset-producer.md index a4a4b1992..7b14b9c56 100644 --- a/.opencode/skills/impeccable/reference/degraded/asset-producer.md +++ b/.opencode/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.opencode/skills/impeccable/reference/degraded/finish-reviewer.md b/.opencode/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.opencode/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.opencode/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.opencode/skills/impeccable/reference/new-work.md b/.opencode/skills/impeccable/reference/new-work.md index c8426e993..3b7309962 100644 --- a/.opencode/skills/impeccable/reference/new-work.md +++ b/.opencode/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.opencode/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.opencode/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.opencode/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.opencode/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.opencode/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.opencode/skills/impeccable/scripts/VERSION b/.opencode/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.opencode/skills/impeccable/scripts/VERSION +++ b/.opencode/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.pi/skills/impeccable/SKILL.md b/.pi/skills/impeccable/SKILL.md index 927c37a91..9d5429dbe 100644 --- a/.pi/skills/impeccable/SKILL.md +++ b/.pi/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 license: Apache 2.0 allowed-tools: - Bash(npx impeccable *) diff --git a/.pi/skills/impeccable/reference/component-review.md b/.pi/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..be65a660e --- /dev/null +++ b/.pi/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.pi/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.pi/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.pi/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.pi/skills/impeccable/reference/degraded/asset-producer.md b/.pi/skills/impeccable/reference/degraded/asset-producer.md index 5060196e3..3843ae02d 100644 --- a/.pi/skills/impeccable/reference/degraded/asset-producer.md +++ b/.pi/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.pi/skills/impeccable/reference/degraded/finish-reviewer.md b/.pi/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.pi/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.pi/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.pi/skills/impeccable/reference/new-work.md b/.pi/skills/impeccable/reference/new-work.md index a1588434b..7dcbf50d6 100644 --- a/.pi/skills/impeccable/reference/new-work.md +++ b/.pi/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.pi/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.pi/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.pi/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.pi/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.pi/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.pi/skills/impeccable/scripts/VERSION b/.pi/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.pi/skills/impeccable/scripts/VERSION +++ b/.pi/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.qoder/skills/impeccable/SKILL.md b/.qoder/skills/impeccable/SKILL.md index 0185a2563..e246f9748 100644 --- a/.qoder/skills/impeccable/SKILL.md +++ b/.qoder/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 user-invocable: true argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 diff --git a/.qoder/skills/impeccable/reference/component-review.md b/.qoder/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..eb80054d0 --- /dev/null +++ b/.qoder/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.qoder/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.qoder/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.qoder/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.qoder/skills/impeccable/reference/degraded/asset-producer.md b/.qoder/skills/impeccable/reference/degraded/asset-producer.md index 217d7e030..5f3ea7643 100644 --- a/.qoder/skills/impeccable/reference/degraded/asset-producer.md +++ b/.qoder/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.qoder/skills/impeccable/reference/degraded/finish-reviewer.md b/.qoder/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.qoder/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.qoder/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.qoder/skills/impeccable/reference/new-work.md b/.qoder/skills/impeccable/reference/new-work.md index 88c3dc7e6..93edf6fbc 100644 --- a/.qoder/skills/impeccable/reference/new-work.md +++ b/.qoder/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.qoder/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.qoder/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.qoder/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.qoder/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.qoder/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.qoder/skills/impeccable/scripts/VERSION b/.qoder/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.qoder/skills/impeccable/scripts/VERSION +++ b/.qoder/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.rovodev/skills/impeccable/SKILL.md b/.rovodev/skills/impeccable/SKILL.md index bac80790a..6c97bcbbc 100644 --- a/.rovodev/skills/impeccable/SKILL.md +++ b/.rovodev/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 user-invocable: true argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 diff --git a/.rovodev/skills/impeccable/reference/component-review.md b/.rovodev/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..83152775a --- /dev/null +++ b/.rovodev/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.rovodev/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.rovodev/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.rovodev/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.rovodev/skills/impeccable/reference/degraded/asset-producer.md b/.rovodev/skills/impeccable/reference/degraded/asset-producer.md index 1f1bb5175..c1a1c1ec5 100644 --- a/.rovodev/skills/impeccable/reference/degraded/asset-producer.md +++ b/.rovodev/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.rovodev/skills/impeccable/reference/degraded/finish-reviewer.md b/.rovodev/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.rovodev/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.rovodev/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.rovodev/skills/impeccable/reference/new-work.md b/.rovodev/skills/impeccable/reference/new-work.md index fa17766d0..f49f6e018 100644 --- a/.rovodev/skills/impeccable/reference/new-work.md +++ b/.rovodev/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.rovodev/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.rovodev/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.rovodev/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.rovodev/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.rovodev/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.rovodev/skills/impeccable/scripts/VERSION b/.rovodev/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.rovodev/skills/impeccable/scripts/VERSION +++ b/.rovodev/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.trae-cn/skills/impeccable/SKILL.md b/.trae-cn/skills/impeccable/SKILL.md index 9aaa2f890..287507c62 100644 --- a/.trae-cn/skills/impeccable/SKILL.md +++ b/.trae-cn/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 user-invocable: true argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 diff --git a/.trae-cn/skills/impeccable/reference/component-review.md b/.trae-cn/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..530ce3cdf --- /dev/null +++ b/.trae-cn/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.trae-cn/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.trae-cn/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.trae-cn/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.trae-cn/skills/impeccable/reference/degraded/asset-producer.md b/.trae-cn/skills/impeccable/reference/degraded/asset-producer.md index 875327ce9..84248c47b 100644 --- a/.trae-cn/skills/impeccable/reference/degraded/asset-producer.md +++ b/.trae-cn/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.trae-cn/skills/impeccable/reference/degraded/finish-reviewer.md b/.trae-cn/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.trae-cn/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.trae-cn/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.trae-cn/skills/impeccable/reference/new-work.md b/.trae-cn/skills/impeccable/reference/new-work.md index ceebf5433..1cde4a46e 100644 --- a/.trae-cn/skills/impeccable/reference/new-work.md +++ b/.trae-cn/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.trae-cn/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.trae-cn/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.trae-cn/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.trae-cn/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.trae-cn/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.trae-cn/skills/impeccable/scripts/VERSION b/.trae-cn/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.trae-cn/skills/impeccable/scripts/VERSION +++ b/.trae-cn/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.trae/skills/impeccable/SKILL.md b/.trae/skills/impeccable/SKILL.md index 7cffd1384..9d5c4a7c9 100644 --- a/.trae/skills/impeccable/SKILL.md +++ b/.trae/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 user-invocable: true argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 diff --git a/.trae/skills/impeccable/reference/component-review.md b/.trae/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..6969fcda2 --- /dev/null +++ b/.trae/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.trae/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.trae/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.trae/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.trae/skills/impeccable/reference/degraded/asset-producer.md b/.trae/skills/impeccable/reference/degraded/asset-producer.md index f6106c722..0e39a1263 100644 --- a/.trae/skills/impeccable/reference/degraded/asset-producer.md +++ b/.trae/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.trae/skills/impeccable/reference/degraded/finish-reviewer.md b/.trae/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.trae/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.trae/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.trae/skills/impeccable/reference/new-work.md b/.trae/skills/impeccable/reference/new-work.md index c54642cb5..677812423 100644 --- a/.trae/skills/impeccable/reference/new-work.md +++ b/.trae/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.trae/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.trae/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.trae/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.trae/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.trae/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.trae/skills/impeccable/scripts/VERSION b/.trae/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.trae/skills/impeccable/scripts/VERSION +++ b/.trae/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.veto/skills/impeccable/SKILL.md b/.veto/skills/impeccable/SKILL.md index 479acffaf..74ea1c077 100644 --- a/.veto/skills/impeccable/SKILL.md +++ b/.veto/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 license: Apache 2.0 --- diff --git a/.veto/skills/impeccable/reference/component-review.md b/.veto/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..5841c60db --- /dev/null +++ b/.veto/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.veto/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.veto/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.veto/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.veto/skills/impeccable/reference/degraded/asset-producer.md b/.veto/skills/impeccable/reference/degraded/asset-producer.md index 7f5df024b..503ec8970 100644 --- a/.veto/skills/impeccable/reference/degraded/asset-producer.md +++ b/.veto/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.veto/skills/impeccable/reference/degraded/finish-reviewer.md b/.veto/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.veto/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.veto/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.veto/skills/impeccable/reference/new-work.md b/.veto/skills/impeccable/reference/new-work.md index 8fd33a9d2..085902fb1 100644 --- a/.veto/skills/impeccable/reference/new-work.md +++ b/.veto/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.veto/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.veto/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.veto/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.veto/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.veto/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.veto/skills/impeccable/scripts/VERSION b/.veto/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.veto/skills/impeccable/scripts/VERSION +++ b/.veto/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/.vibe/skills/impeccable/SKILL.md b/.vibe/skills/impeccable/SKILL.md index b1faaa89e..e84fcdee5 100644 --- a/.vibe/skills/impeccable/SKILL.md +++ b/.vibe/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 user-invocable: true license: Apache 2.0 allowed-tools: diff --git a/.vibe/skills/impeccable/reference/component-review.md b/.vibe/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..e965dd818 --- /dev/null +++ b/.vibe/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `.vibe/skills/impeccable/scripts/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `.vibe/skills/impeccable/scripts/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `.vibe/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/.vibe/skills/impeccable/reference/degraded/asset-producer.md b/.vibe/skills/impeccable/reference/degraded/asset-producer.md index aec84105d..420392311 100644 --- a/.vibe/skills/impeccable/reference/degraded/asset-producer.md +++ b/.vibe/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/.vibe/skills/impeccable/reference/degraded/finish-reviewer.md b/.vibe/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/.vibe/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.vibe/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/.vibe/skills/impeccable/reference/new-work.md b/.vibe/skills/impeccable/reference/new-work.md index 14df4966a..6b90c5e28 100644 --- a/.vibe/skills/impeccable/reference/new-work.md +++ b/.vibe/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`.vibe/skills/impeccable/scripts/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`.vibe/skills/impeccable/scripts/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `.vibe/skills/impeccable/scripts/impeccable build-phase advance` (every verb below runs as `.vibe/skills/impeccable/scripts/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `.vibe/skills/impeccable/scripts/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/.vibe/skills/impeccable/scripts/VERSION b/.vibe/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/.vibe/skills/impeccable/scripts/VERSION +++ b/.vibe/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/Cargo.lock b/Cargo.lock index adcbc3b2a..c6f92cd29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -525,7 +525,7 @@ dependencies = [ [[package]] name = "impeccable" -version = "0.1.5" +version = "0.1.6" dependencies = [ "base64", "impeccable-browser", @@ -540,11 +540,12 @@ dependencies = [ "impeccable-live", "impeccable-skills", "serde_json", + "sha2", ] [[package]] name = "impeccable-browser" -version = "0.1.5" +version = "0.1.6" dependencies = [ "base64", "impeccable-core", @@ -552,13 +553,14 @@ dependencies = [ "percent-encoding", "png", "serde_json", + "sha2", "tungstenite", "url", ] [[package]] name = "impeccable-bundle" -version = "0.1.5" +version = "0.1.6" dependencies = [ "base64", "impeccable-core", @@ -567,14 +569,14 @@ dependencies = [ [[package]] name = "impeccable-common" -version = "0.1.5" +version = "0.1.6" dependencies = [ "libc", ] [[package]] name = "impeccable-comp" -version = "0.1.5" +version = "0.1.6" dependencies = [ "image", "once_cell", @@ -586,7 +588,7 @@ dependencies = [ [[package]] name = "impeccable-comp-verbs" -version = "0.1.5" +version = "0.1.6" dependencies = [ "impeccable-common", "impeccable-comp", @@ -600,10 +602,11 @@ dependencies = [ [[package]] name = "impeccable-context" -version = "0.1.5" +version = "0.1.6" dependencies = [ "flate2", "impeccable-common", + "impeccable-comp", "impeccable-core", "once_cell", "regex", @@ -619,7 +622,7 @@ dependencies = [ [[package]] name = "impeccable-core" -version = "0.1.5" +version = "0.1.6" dependencies = [ "impeccable-core", "impeccable-foundation", @@ -631,7 +634,7 @@ dependencies = [ [[package]] name = "impeccable-detect" -version = "0.1.5" +version = "0.1.6" dependencies = [ "impeccable-common", "impeccable-core", @@ -643,7 +646,7 @@ dependencies = [ [[package]] name = "impeccable-foundation" -version = "0.1.5" +version = "0.1.6" dependencies = [ "cssparser", "once_cell", @@ -656,9 +659,10 @@ dependencies = [ [[package]] name = "impeccable-hook" -version = "0.1.5" +version = "0.1.6" dependencies = [ "impeccable-common", + "impeccable-comp-verbs", "impeccable-context", "impeccable-core", "impeccable-detect", @@ -670,7 +674,7 @@ dependencies = [ [[package]] name = "impeccable-html" -version = "0.1.5" +version = "0.1.6" dependencies = [ "cssparser", "ego-tree", @@ -691,7 +695,7 @@ dependencies = [ [[package]] name = "impeccable-live" -version = "0.1.5" +version = "0.1.6" dependencies = [ "getrandom 0.2.17", "impeccable-common", @@ -709,7 +713,7 @@ dependencies = [ [[package]] name = "impeccable-skills" -version = "0.1.5" +version = "0.1.6" dependencies = [ "impeccable-common", "impeccable-context", @@ -728,7 +732,7 @@ dependencies = [ [[package]] name = "impeccable-wasm" -version = "0.1.5" +version = "0.1.6" dependencies = [ "impeccable-core", "impeccable-detect", @@ -1759,7 +1763,7 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "xtask" -version = "0.1.5" +version = "0.1.6" dependencies = [ "impeccable-bundle", ] diff --git a/Cargo.toml b/Cargo.toml index a9f9ff72a..e61da5e5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.1.5" +version = "0.1.6" edition = "2021" license = "Apache-2.0" publish = false diff --git a/ENGINE_VERSION b/ENGINE_VERSION index 9faa1b7a7..c946ee616 100644 --- a/ENGINE_VERSION +++ b/ENGINE_VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/bun.lock b/bun.lock index 7d9782e1d..f92a48661 100644 --- a/bun.lock +++ b/bun.lock @@ -19,11 +19,11 @@ "zod": "^4.3.6", }, "optionalDependencies": { - "@impeccable/cli-darwin-arm64": "0.1.5", - "@impeccable/cli-darwin-x64": "0.1.5", - "@impeccable/cli-linux-arm64": "0.1.5", - "@impeccable/cli-linux-x64": "0.1.5", - "@impeccable/cli-windows-x64": "0.1.5", + "@impeccable/cli-darwin-arm64": "0.1.6", + "@impeccable/cli-darwin-x64": "0.1.6", + "@impeccable/cli-linux-arm64": "0.1.6", + "@impeccable/cli-linux-x64": "0.1.6", + "@impeccable/cli-windows-x64": "0.1.6", }, }, }, @@ -72,16 +72,6 @@ "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], - "@impeccable/cli-darwin-arm64": ["@impeccable/cli-darwin-arm64@0.1.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "impeccable-darwin-arm64": "bin/impeccable" } }, "sha512-RvSrbsvzHsyfVaL693mMiWWLauZXas00F5VrNoaqOUvOm478HJUIUnl77H393F9k9LWZNiNFLwIinRnGJ92naA=="], - - "@impeccable/cli-darwin-x64": ["@impeccable/cli-darwin-x64@0.1.5", "", { "os": "darwin", "cpu": "x64", "bin": { "impeccable-darwin-x64": "bin/impeccable" } }, "sha512-wYfhNhVtgosKIjxxWdJ3X/KydASdDIAAdmng8shKUgfiCkY9Tld0psuhVJOdkKoJaRajagp7a2f/tCNN1xdBZA=="], - - "@impeccable/cli-linux-arm64": ["@impeccable/cli-linux-arm64@0.1.5", "", { "os": "linux", "cpu": "arm64", "bin": { "impeccable-linux-arm64": "bin/impeccable" } }, "sha512-gjWJbOa7/wAECkIOac/x6mq43lDGaGwH87/ErsnrFwk/s5hgtlBF0sPePeB30e/lu1VzGMJcXVUEA8EULv4cCg=="], - - "@impeccable/cli-linux-x64": ["@impeccable/cli-linux-x64@0.1.5", "", { "os": "linux", "cpu": "x64", "bin": { "impeccable-linux-x64": "bin/impeccable" } }, "sha512-JZU7kd7s4DOz9w2gwbbpI1u3YLhrcAUNZg/awjZcQFHJwpizv/MhPVUjHKYuChrPn+Oxlpw7KU8adKY1YrSq8g=="], - - "@impeccable/cli-windows-x64": ["@impeccable/cli-windows-x64@0.1.5", "", { "os": "win32", "cpu": "x64", "bin": { "impeccable-windows-x64": "bin/impeccable.exe" } }, "sha512-kVZFjQIjb84mF2klxTspxE1OilalO/OmYDQnLkCJeDJCQLr6+L6HvbghJ1TChq87uyMu4GQNDPr9VV+h+Z+tCQ=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], diff --git a/crates/browser/Cargo.toml b/crates/browser/Cargo.toml index 4e9997e67..2a0a7c9c2 100644 --- a/crates/browser/Cargo.toml +++ b/crates/browser/Cargo.toml @@ -14,3 +14,5 @@ png = "0.18" base64 = "0.22" url = "2" percent-encoding = "2" + +sha2 = "0.10" diff --git a/crates/browser/src/cdp.rs b/crates/browser/src/cdp.rs index 377d1bd9e..c7132b382 100644 --- a/crates/browser/src/cdp.rs +++ b/crates/browser/src/cdp.rs @@ -23,7 +23,7 @@ use std::process::{Child, Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; -use serde_json::{json, Map, Value}; +use serde_json::{Map, Value, json}; use tungstenite::client::IntoClientRequest; use tungstenite::protocol::WebSocketConfig; use tungstenite::{Message, WebSocket}; @@ -117,6 +117,31 @@ pub struct Browser { worker_owner: HashMap, } +// A timestamp is not a reservation: concurrent calls can observe the same tick. +// Exclusive mkdir also avoids reusing a stale profile from a previous process. +fn reserve_profile(parent: &std::path::Path, stamp: u128) -> CdpResult { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + for _ in 0..128 { + let serial = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = parent.join(format!( + "impeccable_dev_chrome_profile-{}-{stamp}-{serial}", + std::process::id() + )); + match std::fs::create_dir(&path) { + Ok(()) => return Ok(path), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => { + return Err(CdpError::new(format!( + "Failed to create a temporary browser profile: {e}" + ))); + } + } + } + Err(CdpError::new( + "Failed to reserve a unique temporary browser profile", + )) +} + impl Browser { /// Launch `executable` headless the way puppeteer does and connect to its /// DevTools websocket. @@ -131,14 +156,7 @@ impl Browser { .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); - let user_data_dir = std::env::temp_dir().join(format!( - "impeccable_dev_chrome_profile-{}-{}", - std::process::id(), - stamp - )); - std::fs::create_dir_all(&user_data_dir).map_err(|e| { - CdpError::new(format!("Failed to create a temporary browser profile: {e}")) - })?; + let user_data_dir = reserve_profile(&std::env::temp_dir(), stamp)?; args.push(format!("--user-data-dir={}", user_data_dir.display())); let mut child = match Command::new(executable) @@ -272,6 +290,12 @@ impl Browser { } } + /// Browser-reported capture environment, independent of page script overrides. + pub fn version(&mut self) -> CdpResult { + self.conn + .send(None, "Browser.getVersion", json!({}), PROTOCOL_TIMEOUT) + } + /// `browser.newPage()`: a fresh target in the default context, attached /// flat, with puppeteer's page setup applied. pub fn new_page(&mut self) -> CdpResult> { @@ -307,6 +331,8 @@ impl Browser { swapped: false, same_document_navigation: false, iframe_sessions: HashSet::new(), + response_capture: None, + execution_contexts: HashMap::new(), }; page.initialize()?; Ok(page) @@ -403,7 +429,10 @@ impl Connection { // JS `{ ...request.headers(), authorization: header }`: puppeteer's // request.headers() lowercases names, so authorization overrides. let mut headers: Vec = Vec::new(); - if let Some(obj) = params.pointer("/request/headers").and_then(Value::as_object) { + if let Some(obj) = params + .pointer("/request/headers") + .and_then(Value::as_object) + { for (k, v) in obj { let name = k.to_ascii_lowercase(); if name == "authorization" { @@ -460,7 +489,7 @@ impl Connection { None => { return Err(CdpError::new(format!( "{method} timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed." - ))) + ))); } }; if msg.get("id").and_then(Value::as_u64) == Some(id) { @@ -579,6 +608,18 @@ pub struct Page<'a> { same_document_navigation: bool, /// Auto-attached OOPIF sessions whose Page events feed the frame map. iframe_sessions: HashSet, + response_capture: Option, + execution_contexts: HashMap, +} + +/// An opaque, document-bound isolated execution context. Never falls back to +/// the page world or a newly navigated document. +pub struct IsolatedWorld { + unique_id: String, + context_id: i64, + session_id: String, + frame_id: String, + loader_id: String, } /// A raw `Runtime.evaluate` outcome. @@ -599,6 +640,63 @@ impl<'a> Page<'a> { out } + /// Start a fresh diagnostic journal before navigation. Cache, authentication, + /// and service-worker behavior remain unchanged. No response is refetched. + pub fn begin_response_capture(&mut self) -> CdpResult<()> { + self.send( + "Network.enable", + json!({ + "maxTotalBufferSize": crate::response_capture::MAX_TOTAL, + "maxResourceBufferSize": crate::response_capture::MAX_BODY + }), + )?; + self.response_capture = Some(crate::response_capture::ResponseCapture::default()); + Ok(()) + } + + /// All requests observed for the current document, including failed/missing dependencies. + pub fn observed_response_urls(&mut self) -> CdpResult> { + self.pump_events(); + let capture=self.response_capture.as_ref().ok_or_else(||CdpError::new("response capture is not enabled"))?; + let loader=self.frames.get(&self.main_frame_id).map(|f|f.loader_id.as_str()).unwrap_or(""); + Ok(capture.urls(&self.main_frame_id,loader)) + } + + /// Retrieve observed main-document response payloads for exact URLs. + /// This is transport evidence only: it does not prove rendered use or fidelity. + pub fn response_evidence( + &mut self, + urls: &[String], + ) -> CdpResult { + self.pump_events(); + let capture = self + .response_capture + .as_ref() + .ok_or_else(|| CdpError::new("response capture is not enabled"))?; + let frame = self.main_frame_id.clone(); + let loader = self + .frames + .get(&frame) + .map(|f| f.loader_id.clone()) + .unwrap_or_default(); + let revision = capture.revision; + let pending = capture.pending(urls, &frame, &loader); + for (index, request_id) in pending { + let body = self + .send("Network.getResponseBody", json!({"requestId": request_id})) + .map_err(|e| e.message); + self.response_capture + .as_mut() + .unwrap() + .store_body(index, body); + } + Ok(self + .response_capture + .as_ref() + .unwrap() + .evidence(urls, &frame, &loader, revision)) + } + /// puppeteer's `CdpPage` + `FrameManager.initialize` for a new target. fn initialize(&mut self) -> CdpResult<()> { self.send("Page.enable", json!({}))?; @@ -662,6 +760,30 @@ impl<'a> Page<'a> { let method = ev.get("method").and_then(Value::as_str).unwrap_or(""); let session = ev.get("sessionId").and_then(Value::as_str).unwrap_or(""); let params = ev.get("params").cloned().unwrap_or(Value::Null); + if session == self.session_id { + match method { + "Runtime.executionContextCreated" => { + if let (Some(id), Some(unique)) = ( + params.pointer("/context/id").and_then(Value::as_i64), + params.pointer("/context/uniqueId").and_then(Value::as_str), + ) { + if self.execution_contexts.len() < 512 { + self.execution_contexts.insert(id, unique.to_owned()); + } + } + } + "Runtime.executionContextDestroyed" => { + if let Some(id) = params.get("executionContextId").and_then(Value::as_i64) { + self.execution_contexts.remove(&id); + } + } + "Runtime.executionContextsCleared" => self.execution_contexts.clear(), + _ => {} + } + if let Some(capture) = &mut self.response_capture { + capture.event(method, ¶ms); + } + } let is_page_session = session == self.session_id || self.iframe_sessions.contains(session); match method { "Target.attachedToTarget" => { @@ -892,7 +1014,14 @@ impl<'a> Page<'a> { true } - /// `page.setViewport({width, height})` (EmulationManager#applyViewport). + /// Explicit screenshot-environment preference; never injected as page CSS. + pub fn set_reduced_motion(&mut self, reduce: bool) -> CdpResult<()> { + self.send("Emulation.setEmulatedMedia", json!({"features": [{ + "name": "prefers-reduced-motion", "value": if reduce { "reduce" } else { "no-preference" } + }]}))?; + Ok(()) + } + pub fn set_viewport(&mut self, viewport: Viewport) -> CdpResult<()> { self.send( "Emulation.setDeviceMetricsOverride", @@ -955,7 +1084,7 @@ impl<'a> Page<'a> { other => { return Err(CdpError::new(format!( "Unknown value for options.waitUntil: {other}" - ))) + ))); } }; self.pump_events(); @@ -1043,18 +1172,195 @@ impl<'a> Page<'a> { } } + /// Create an opt-in world with separate JS globals/prototypes, on the current + /// main document. No universal cross-origin access is granted. + pub fn create_isolated_world(&mut self) -> CdpResult { + self.pump_events(); + let frame_id = self.main_frame_id.clone(); + let loader_id = self + .frames + .get(&frame_id) + .map(|f| f.loader_id.clone()) + .unwrap_or_default(); + if frame_id.is_empty() || loader_id.is_empty() { + return Err(CdpError::new("no current document for isolated capture")); + } + let created=self.send("Page.createIsolatedWorld",json!({"frameId":frame_id,"worldName":"impeccable-diagnostic-capture","grantUniveralAccess":false}))?; + let id = created["executionContextId"] + .as_i64() + .ok_or_else(|| CdpError::new("isolated world returned no context id"))?; + let unique_id = self + .execution_contexts + .get(&id) + .cloned() + .ok_or_else(|| CdpError::new("isolated context identity unavailable"))?; + let world = IsolatedWorld { + unique_id, + context_id: id, + session_id: self.session_id.clone(), + frame_id, + loader_id, + }; + self.validate_world(&world)?; + Ok(world) + } + + /// Inspect native DOM coverage, including authored closed shadow roots that + /// page-world selectors cannot see. This never treats hidden trees as absent. + pub fn capture_dom_coverage(&mut self, world: &IsolatedWorld) -> CdpResult { + self.validate_world(world)?; + let tree = self.send("DOM.getDocument", json!({"depth":-1,"pierce":true}))?; + self.validate_world(world)?; + let root = tree + .get("root") + .ok_or_else(|| CdpError::new("native DOM coverage unavailable"))?; + let mut stack = vec![root]; + let mut nodes = 0; + let mut closed = 0; + while let Some(node) = stack.pop() { + nodes += 1; + if nodes > 5000 { + return Err(CdpError::new("native capture DOM exceeds 5000 nodes")); + } + if node.get("shadowRootType").and_then(Value::as_str) == Some("closed") { + closed += 1; + } + for key in ["children", "shadowRoots", "pseudoElements"] { + if let Some(children) = node.get(key).and_then(Value::as_array) { + stack.extend(children); + } + } + for key in ["contentDocument", "templateContent"] { + if let Some(child) = node.get(key).filter(|v| v.is_object()) { + stack.push(child); + } + } + } + Ok(json!({"inspectedNodes":nodes,"closedShadowRoots":closed})) + } + + /// Inspector-owned stylesheet; never a page-authored style element. + pub fn create_capture_stylesheet(&mut self, world: &IsolatedWorld) -> CdpResult { + self.validate_world(world)?; + self.send("DOM.enable", json!({}))?; + self.send("CSS.enable", json!({}))?; + let value = self.send("CSS.createStyleSheet", json!({"frameId":world.frame_id}))?; + self.validate_world(world)?; + value["styleSheetId"].as_str().map(str::to_owned) + .ok_or_else(|| CdpError::new("capture stylesheet unavailable")) + } + + pub fn set_capture_stylesheet(&mut self, world: &IsolatedWorld, sheet: &str, text: &str) -> CdpResult<()> { + self.validate_world(world)?; + if text.len() > 65536 { return Err(CdpError::new("capture stylesheet exceeds bound")); } + self.send("CSS.setStyleSheetText", json!({"styleSheetId":sheet,"text":text}))?; + self.validate_world(world) + } + + /// Main-document generated boxes from native layout, indexed in querySelectorAll order. + /// Do not infer pseudo geometry from its owner's rectangle. + pub fn capture_pseudo_geometry(&mut self, world: &IsolatedWorld, retained: &[Value]) -> CdpResult { + self.validate_world(world)?; + // Text-only generated boxes need no image geometry. Query candidates in + // the isolated world before the bounded native layout lookup. + let candidates = self.evaluate_value_in_world(world, + "[...document.querySelectorAll('*')].flatMap((el,index)=>['before','after'].filter(p=>{const s=getComputedStyle(el,'::'+p);return s.backgroundImage!=='none'||s.content.includes('url(')}).map(pseudo=>({index,pseudo})))")?; + let mut candidates = candidates.as_array().ok_or_else(|| CdpError::new("pseudo candidates unavailable"))?.clone(); + // Suppression can remove a pseudo's only URL. Keep its identity eligible + // for a fresh native geometry lookup; never reuse its previous box. + for candidate in retained { + if !candidates.contains(candidate) { candidates.push(candidate.clone()); } + } + if candidates.len()>256 {return Err(CdpError::new("capture pseudo image candidates exceed bound"));} + if candidates.is_empty() {return Ok(json!([]));} + let tree = self.send("DOM.getDocument", json!({"depth":-1}))?; + let mut stack = vec![(tree["root"].clone(), String::new())]; + let mut index = 0usize; + let mut pseudos = Vec::new(); + while let Some((node, selector)) = stack.pop() { + let owner = index; + if node["nodeType"] == 1 { index += 1; } + if index > 5000 { return Err(CdpError::new("capture DOM exceeds bound")); } + if let Some(items) = node["pseudoElements"].as_array() { + for pseudo in items { + if !matches!(pseudo["pseudoType"].as_str(), Some("before" | "after")) { continue; } + if !candidates.iter().any(|c|c["index"].as_u64()==Some(owner as u64)&&c["pseudo"]==pseudo["pseudoType"]) {continue;} + if pseudos.len() >= 256 { return Err(CdpError::new("capture pseudo elements exceed bound")); } + let layout = self.send("DOM.getBoxModel", json!({"nodeId":pseudo["nodeId"]})); + let bounds = layout.ok().and_then(|v| { + let q = v["model"]["border"].as_array()?; + if q.len()!=8 {return None;} + let values: Option>=q.iter().map(Value::as_f64).collect(); + let values=values?; + let xs=[values[0],values[2],values[4],values[6]]; + let ys=[values[1],values[3],values[5],values[7]]; + let x=xs.into_iter().fold(f64::INFINITY,f64::min); + let y=ys.into_iter().fold(f64::INFINITY,f64::min); + Some(json!({"x":x,"y":y,"w":xs.into_iter().fold(f64::NEG_INFINITY,f64::max)-x,"h":ys.into_iter().fold(f64::NEG_INFINITY,f64::max)-y})) + }); + pseudos.push(json!({"index":owner,"pseudo":format!("::{}",pseudo["pseudoType"].as_str().unwrap()),"selector":selector,"backendNodeId":pseudo["backendNodeId"],"box":bounds})); + } + } + if let Some(children)=node["children"].as_array() { + let elements: Vec<_>=children.iter().filter(|n|n["nodeType"]==1).collect(); + for (i,child) in elements.into_iter().enumerate().rev() { + let path=if selector.is_empty(){":root".to_owned()}else{format!("{selector}>:nth-child({})",i+1)}; + stack.push((child.clone(),path)); + } + } + } + self.validate_world(world)?; + Ok(json!(pseudos)) + } + + fn validate_world(&mut self, world: &IsolatedWorld) -> CdpResult<()> { + self.pump_events(); + if self.session_id != world.session_id + || self.main_frame_id != world.frame_id + || self + .frames + .get(&world.frame_id) + .map(|f| f.loader_id.as_str()) + != Some(world.loader_id.as_str()) + || self.execution_contexts.get(&world.context_id) != Some(&world.unique_id) + { + return Err(CdpError::new( + "isolated capture context was destroyed or document changed", + )); + } + Ok(()) + } + + pub fn evaluate_value_in_world( + &mut self, + world: &IsolatedWorld, + expression: &str, + ) -> CdpResult { + self.validate_world(world)?; + let result = self.evaluate_with_context(expression, Some(&world.unique_id))?; + self.validate_world(world)?; + match result { + EvalOutcome::Value(v) => Ok(v), + EvalOutcome::Exception(message) => Err(CdpError::new(message)), + } + } + /// `page.evaluate()`: `Runtime.evaluate` with /// `awaitPromise` + `returnByValue`, in the main world. pub fn evaluate(&mut self, expression: &str) -> CdpResult { - let res = self.send( - "Runtime.evaluate", - json!({ - "expression": expression, - "returnByValue": true, - "awaitPromise": true, - "userGesture": true, - }), - ); + self.evaluate_with_context(expression, None) + } + + fn evaluate_with_context( + &mut self, + expression: &str, + unique_context: Option<&str>, + ) -> CdpResult { + let mut params = json!({"expression":expression,"returnByValue":true,"awaitPromise":true,"userGesture":true}); + if let Some(id) = unique_context { + params["uniqueContextId"] = json!(id); + } + let res = self.send("Runtime.evaluate", params); let res = match res { Ok(r) => r, Err(e) => { @@ -1090,6 +1396,22 @@ impl<'a> Page<'a> { } } + /// Capture the current viewport without Chromium's beyond-viewport resize. + /// Use for observations that must not trigger responsive source selection. + pub fn screenshot_viewport(&mut self) -> CdpResult { + let res = self.send( + "Page.captureScreenshot", + json!({ + "format": "png", "optimizeForSpeed": false, + "fromSurface": true, "captureBeyondViewport": false, + }), + )?; + res.get("data") + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| CdpError::new("viewport screenshot returned no PNG")) + } + /// `page.screenshot({ encoding: 'base64', clip, captureBeyondViewport: true })`. /// Returns base64 PNG data. pub fn screenshot_clip( @@ -1274,6 +1596,29 @@ mod tests { assert_eq!(client_error_message(&syntax), "Unexpected token '}'"); } + #[test] + fn concurrent_launches_with_identical_clock_ticks_reserve_distinct_profiles() { + let profiles = std::thread::scope(|scope| { + let handles: Vec<_> = (0..32) + .map(|_| scope.spawn(|| reserve_profile(&std::env::temp_dir(), 7).unwrap())) + .collect(); + handles + .into_iter() + .map(|h| h.join().unwrap()) + .collect::>() + }); + assert_eq!( + profiles + .iter() + .collect::>() + .len(), + 32 + ); + for path in profiles { + std::fs::remove_dir(path).unwrap(); + } + } + #[test] fn default_args_shape() { let args = default_chrome_args(&[], false); diff --git a/crates/browser/src/html_snapshot.rs b/crates/browser/src/html_snapshot.rs new file mode 100644 index 000000000..bc9b210e3 --- /dev/null +++ b/crates/browser/src/html_snapshot.rs @@ -0,0 +1,382 @@ +//! Immutable static-HTML capture transport, extracted from the existing native capture adapter. +//! Component previews use a passive CSP; scripts, frames and external resources are not executable. +//! The native caller owns the selection; private bound inputs are never HTTP routes. +use sha2::{Digest, Sha256}; +fn hash(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +use serde_json::{Value, json}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File}, + io::{Read, Write}, + net::{TcpListener, TcpStream}, + path::{Component, Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +const MAX_FILES: usize = 1024; +const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_TOTAL_BYTES: usize = 128 * 1024 * 1024; + +pub struct SnapshotSelection { + pub root: PathBuf, + pub entry: String, + /// Explicit local static dependencies, including the entry. No directory crawling. + pub served: Vec, + /// Spec, reference and other gate inputs. Hashed but never served unless also in served. + pub bound: Vec, +} +pub struct HtmlSnapshot { + root: Option, + entry: String, + served: BTreeSet, + files: BTreeMap>, + manifest: Value, + digest: String, +} +impl HtmlSnapshot { + /// Reuse a caller-owned immutable snapshot. The caller must retain and verify + /// the source binding before committing evidence; this constructor performs no disk reads. + pub fn from_pinned(entry: String, files: BTreeMap>) -> Result { + valid_relative(&entry)?; + if !matches!( + Path::new(&entry).extension().and_then(|s| s.to_str()), + Some("html" | "htm") + ) || !files.contains_key(&entry) + { + return Err("pinned snapshot requires its HTML entry".into()); + } + if files.len() > MAX_FILES || files.values().map(Vec::len).sum::() > MAX_TOTAL_BYTES + { + return Err("snapshot exceeds capture budget".into()); + } + for (name, bytes) in &files { + valid_relative(name)?; + if mime(name).is_none() || bytes.len() as u64 > MAX_FILE_BYTES { + return Err(format!("unsupported capture input: {name}")); + } + } + let manifest = json!({"schema":"native-html-input-snapshot-v1","entry":entry,"files":files.iter().map(|(name,bytes)|json!({"path":name,"sha256":hash(bytes),"bytes":bytes.len(),"served":true})).collect::>()}); + let digest = hash(&serde_json::to_vec(&manifest).map_err(|e| e.to_string())?); + Ok(Self { + root: None, + entry, + served: files.keys().cloned().collect(), + files, + manifest, + digest, + }) + } + + pub fn freeze(selection: SnapshotSelection) -> Result { + let root = fs::canonicalize(&selection.root).map_err(|e| format!("snapshot root: {e}"))?; + if !root.is_dir() { + return Err("snapshot root is not a directory".into()); + } + valid_relative(&selection.entry)?; + if !matches!( + Path::new(&selection.entry) + .extension() + .and_then(|s| s.to_str()), + Some("html" | "htm") + ) { + return Err( + "snapshot requires an HTML entry; framework build binding is unsupported".into(), + ); + } + let served: BTreeSet<_> = selection.served.into_iter().collect(); + if !served.contains(&selection.entry) { + return Err("selected entry is not served".into()); + } + for name in &served { + valid_relative(name)?; + if name.split('/').any(|p| p.starts_with('.')) || mime(name).is_none() { + return Err(format!("not an allowed static dependency: {name}")); + } + } + let names: BTreeSet<_> = served.iter().cloned().chain(selection.bound).collect(); + if names.len() > MAX_FILES { + return Err("snapshot exceeds file limit".into()); + } + let mut files = BTreeMap::new(); + let mut total = 0; + for name in names { + let bytes = read_input(&root, &name)?; + total += bytes.len(); + if total > MAX_TOTAL_BYTES { + return Err("snapshot exceeds total byte limit".into()); + } + files.insert(name, bytes); + } + let manifest = json!({"schema":"native-html-input-snapshot-v1","entry":selection.entry, + "files":files.iter().map(|(name,bytes)|json!({"path":name,"sha256":hash(bytes),"bytes":bytes.len(),"served":served.contains(name)})).collect::>()}); + let digest = hash(&serde_json::to_vec(&manifest).map_err(|e| e.to_string())?); + let snapshot = Self { + root: Some(root), + entry: selection.entry, + served, + files, + manifest, + digest, + }; + snapshot.verify_current()?; + Ok(snapshot) + } + pub fn digest(&self) -> &str { + &self.digest + } + pub fn manifest(&self) -> &Value { + &self.manifest + } + pub fn entry(&self) -> &str { + &self.entry + } + pub fn bytes(&self, name: &str) -> Option<&[u8]> { + self.files.get(name).map(Vec::as_slice) + } + pub fn verify_current(&self) -> Result<(), String> { + let Some(root) = &self.root else { + return Ok(()); + }; + for (name, bytes) in &self.files { + if read_input(root, name)? != *bytes { + return Err(format!("capture input changed: {name}")); + } + } + Ok(()) + } + /// Strict origin-form routes. Decode percent-encoded UTF-8, but never separators. + pub fn serve_path(&self, target: &str) -> Option { + let raw = target.strip_prefix('/')?.split('?').next()?; + let mut bytes = Vec::new(); + let input = raw.as_bytes(); + let mut i = 0; + while i < input.len() { + if input[i] == b'%' { + let hex = std::str::from_utf8(input.get(i + 1..i + 3)?).ok()?; + let byte = u8::from_str_radix(hex, 16).ok()?; + if matches!(byte, b'/' | b'\\' | 0) { + return None; + } + bytes.push(byte); + i += 3; + } else { + bytes.push(input[i]); + i += 1; + } + } + let name = String::from_utf8(bytes).ok()?; + valid_relative(&name).ok()?; + self.served.contains(&name).then_some(name) + } + pub fn serve(self: &Arc) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?; + let addr = listener.local_addr().map_err(|e| e.to_string())?; + listener.set_nonblocking(true).map_err(|e| e.to_string())?; + let host = addr.to_string(); + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = stop.clone(); + let snapshot = self.clone(); + let worker_host = host.clone(); + let worker = thread::spawn(move || { + while !worker_stop.load(Ordering::Acquire) { + match listener.accept() { + Ok((mut stream, _)) => { + let _ = respond(&mut stream, &worker_host, &snapshot); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)) + } + Err(_) => break, + } + } + }); + Ok(SnapshotServer { + host, + entry: self.entry.clone(), + stop, + worker: Some(worker), + }) + } +} +fn valid_relative(name: &str) -> Result<(), String> { + if name.is_empty() + || name.contains(['\\', '\0', '?', '#', ':']) + || name + .split('/') + .any(|p| p.is_empty() || p == "." || p == "..") + || Path::new(name) + .components() + .any(|c| !matches!(c, Component::Normal(_))) + { + return Err(format!("invalid snapshot path: {name}")); + } + Ok(()) +} +fn read_input(root: &Path, name: &str) -> Result, String> { + valid_relative(name)?; + let inspect = || -> Result { + if fs::symlink_metadata(root) + .map_err(|e| e.to_string())? + .file_type() + .is_symlink() + { + return Err("snapshot root replaced by symlink".into()); + } + let mut path = root.to_path_buf(); + for part in Path::new(name).components() { + path.push(part); + let m = + fs::symlink_metadata(&path).map_err(|e| format!("snapshot input {name}: {e}"))?; + if m.file_type().is_symlink() { + return Err(format!("symlink snapshot input: {name}")); + } + } + let m = fs::symlink_metadata(&path).map_err(|e| e.to_string())?; + if !m.is_file() || m.len() > MAX_FILE_BYTES { + return Err(format!("unsupported or oversized input: {name}")); + } + Ok(m) + }; + let before = inspect()?; + let mut file = File::open(root.join(name)).map_err(|e| e.to_string())?; + let opened = file.metadata().map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if before.dev() != opened.dev() || before.ino() != opened.ino() { + return Err(format!("input replaced while opening: {name}")); + } + } + if !opened.is_file() || opened.len() > MAX_FILE_BYTES { + return Err(format!("unsupported or oversized input: {name}")); + } + let mut bytes = Vec::new(); + (&mut file) + .take(MAX_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + let after = inspect()?; + if bytes.len() as u64 > MAX_FILE_BYTES + || before.len() != after.len() + || before.modified().ok() != after.modified().ok() + || bytes.len() as u64 != after.len() + { + return Err(format!("input changed while reading: {name}")); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if before.dev() != after.dev() || before.ino() != after.ino() { + return Err(format!("input replaced while reading: {name}")); + } + } + Ok(bytes) +} +fn mime(name: &str) -> Option<&'static str> { + Some(match Path::new(name).extension()?.to_str()? { + "html" | "htm" => "text/html; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "avif" => "image/avif", + "ico" => "image/x-icon", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "otf" => "font/otf", + _ => return None, + }) +} +fn encode_path(path: &str) -> String { + path.bytes() + .map(|b| { + if b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.' | b'~') { + (b as char).to_string() + } else { + format!("%{b:02X}") + } + }) + .collect() +} +pub struct SnapshotServer { + host: String, + entry: String, + stop: Arc, + worker: Option>, +} +impl SnapshotServer { + pub fn entry_url(&self) -> String { + format!("http://{}/{}", self.host, encode_path(&self.entry)) + } +} +impl Drop for SnapshotServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} +fn respond( + stream: &mut TcpStream, + host: &str, + snapshot: &HtmlSnapshot, +) -> Result<(), std::io::Error> { + // BSD/macOS can inherit O_NONBLOCK from the listening socket. Explicitly + // switch accepted streams back before write_all; otherwise large bodies + // stop at EWOULDBLOCK and appear as valid-header/truncated-image responses. + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + stream.set_write_timeout(Some(Duration::from_secs(2)))?; + let mut bytes = Vec::new(); + let mut buf = [0u8; 1024]; + while bytes.len() <= 8192 && !bytes.windows(4).any(|x| x == b"\r\n\r\n") { + let n = stream.read(&mut buf)?; + if n == 0 { + return Ok(()); + } + bytes.extend_from_slice(&buf[..n]); + } + let request = std::str::from_utf8(&bytes).unwrap_or(""); + let mut lines = request.split("\r\n"); + let mut first = lines.next().unwrap_or("").split_whitespace(); + let method = first.next(); + let target = first.next(); + let protocol = first.next(); + let hosts: Vec<_> = lines + .filter_map(|line| line.split_once(':')) + .filter(|(key, _)| key.eq_ignore_ascii_case("host")) + .map(|(_, value)| value.trim()) + .collect(); + let valid = bytes.len() <= 8192 + && method == Some("GET") + && protocol == Some("HTTP/1.1") + && first.next().is_none() + && hosts == [host]; + let route = if valid { + target.and_then(|t| snapshot.serve_path(t)) + } else { + None + }; + let (status, kind, body) = match route.as_deref() { + Some(name) => ("200 OK", mime(name).unwrap(), snapshot.bytes(name).unwrap()), + None => ("404 Not Found", "text/plain", b"Not found".as_slice()), + }; + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: private, max-age=3600, immutable\r\nX-Content-Type-Options: nosniff\r\nContent-Security-Policy: default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\r\n\r\n", + body.len() + )?; + stream.write_all(body) +} diff --git a/crates/browser/src/lib.rs b/crates/browser/src/lib.rs index 98d44b34b..673518152 100644 --- a/crates/browser/src/lib.rs +++ b/crates/browser/src/lib.rs @@ -16,6 +16,8 @@ //! (`createBrowserDetector()`: `waitUntil: 'load'`, `settleMs: 100`). pub mod cdp; +pub mod response_capture; +pub mod html_snapshot; pub mod discovery; pub mod screenshot_contrast; pub mod snapshot_engine; diff --git a/crates/browser/src/response_capture.rs b/crates/browser/src/response_capture.rs new file mode 100644 index 000000000..369d32833 --- /dev/null +++ b/crates/browser/src/response_capture.rs @@ -0,0 +1,308 @@ +//! Opt-in, bounded CDP response evidence. This proves observed bytes, not paint. +use base64::Engine; +use serde_json::Value; +use std::collections::HashMap; + +pub(crate) const MAX_BODY: usize = 16 * 1024 * 1024; +pub(crate) const MAX_TOTAL: usize = 64 * 1024 * 1024; +const MAX_RECORDS: usize = 512; + +#[derive(Debug, Clone)] +pub struct ResponseRecord { + pub request_id: String, + pub url: String, + pub frame_id: String, + pub loader_id: String, + pub status: Option, + pub mime_type: String, + pub from_disk_cache: bool, + pub from_service_worker: bool, + pub complete: bool, + /// CDP-decoded response payload, not HTTP transfer/compression bytes. + pub body: Option>, + pub unavailable_reason: Option, + /// Multiple observed requests for this URL; DOM URL alone cannot bind one. + pub ambiguous_url: bool, +} + +#[derive(Debug)] +pub struct ResponseEvidence { + pub responses: Vec, + pub missing_urls: Vec, + /// Evidence is incomplete when the bounded event journal overflowed. + pub truncated: bool, + /// Network changed while retrieving bodies; callers must capture again. + pub changed_during_collection: bool, + pub revision: u64, +} + +#[derive(Default)] +pub(crate) struct ResponseCapture { + records: Vec, + active: HashMap, + pub revision: u64, + truncated: bool, + body_bytes: usize, +} + +fn string(v: &Value, key: &str) -> String { + v.get(key).and_then(Value::as_str).unwrap_or("").to_owned() +} + +impl ResponseCapture { + pub fn event(&mut self, method: &str, p: &Value) { + if !matches!( + method, + "Network.requestWillBeSent" + | "Network.responseReceived" + | "Network.loadingFinished" + | "Network.loadingFailed" + ) { + return; + } + self.revision += 1; + let id = string(p, "requestId"); + if id.is_empty() { + self.truncated = true; + return; + } + if method == "Network.requestWillBeSent" { + // Redirect hops reuse a request id. Never retrieve the final body's + // bytes on behalf of an earlier hop, even when the URL repeats. + if let Some(old) = self.active.remove(&id) { + self.records[old].unavailable_reason = + Some("request id reused or redirected".into()); + } + if self.records.len() >= MAX_RECORDS { + self.truncated = true; + return; + } + let index = self.records.len(); + self.records.push(ResponseRecord { + request_id: id.clone(), + url: string(&p["request"], "url"), + frame_id: string(p, "frameId"), + loader_id: string(p, "loaderId"), + status: None, + mime_type: String::new(), + from_disk_cache: false, + from_service_worker: false, + complete: false, + body: None, + unavailable_reason: None, + ambiguous_url: false, + }); + self.active.insert(id, index); + return; + } + let Some(&i) = self.active.get(&id) else { + return; + }; + let r = &mut self.records[i]; + match method { + "Network.responseReceived" => { + let response = &p["response"]; + r.status = response["status"].as_f64(); + r.mime_type = string(response, "mimeType"); + r.from_disk_cache = response["fromDiskCache"].as_bool().unwrap_or(false); + r.from_service_worker = response["fromServiceWorker"].as_bool().unwrap_or(false); + if string(response, "url") != r.url { + r.unavailable_reason = Some("response URL differs from request".into()); + } + } + "Network.loadingFinished" => r.complete = true, + "Network.loadingFailed" => { + r.unavailable_reason = Some(format!("request failed: {}", string(p, "errorText"))) + } + _ => {} + } + } + + fn matches(r: &ResponseRecord, urls: &[String], frame: &str, loader: &str) -> bool { + !frame.is_empty() + && !loader.is_empty() + && r.frame_id == frame + && r.loader_id == loader + && urls.contains(&r.url) + } + + pub fn urls(&self, frame: &str, loader: &str) -> Vec { + let mut urls: Vec<_> = self.records.iter().filter(|r|r.frame_id==frame && r.loader_id==loader).map(|r|r.url.clone()).collect(); + urls.sort(); urls.dedup(); urls + } + + pub fn pending(&self, urls: &[String], frame: &str, loader: &str) -> Vec<(usize, String)> { + self.records + .iter() + .enumerate() + .filter(|(_, r)| { + Self::matches(r, urls, frame, loader) + && r.complete + && r.status.is_some() + && r.body.is_none() + && r.unavailable_reason.is_none() + }) + .map(|(i, r)| (i, r.request_id.clone())) + .collect() + } + + pub fn store_body(&mut self, index: usize, result: Result) { + let r = &mut self.records[index]; + if r.unavailable_reason.is_some() { + return; + } + let decoded = result.and_then(|v| { + let body = v["body"].as_str().ok_or("CDP returned no body")?; + let encoded = v["base64Encoded"] + .as_bool() + .ok_or("CDP returned no body encoding")?; + let remaining = MAX_BODY.min(MAX_TOTAL.saturating_sub(self.body_bytes)); + if body.len() + > if encoded { + remaining.div_ceil(3) * 4 + } else { + remaining + } + { + return Err("response body exceeds capture budget".into()); + } + let bytes = if encoded { + base64::engine::general_purpose::STANDARD + .decode(body) + .map_err(|_| "invalid CDP body encoding".to_string())? + } else { + body.as_bytes().to_vec() + }; + if bytes.len() > remaining { + return Err("response body exceeds capture budget".into()); + } + Ok(bytes) + }); + match decoded { + Ok(bytes) => { + self.body_bytes += bytes.len(); + r.body = Some(bytes); + } + Err(reason) => r.unavailable_reason = Some(reason), + } + } + + pub fn evidence( + &self, + urls: &[String], + frame: &str, + loader: &str, + before: u64, + ) -> ResponseEvidence { + let mut responses: Vec<_> = self + .records + .iter() + .filter(|r| Self::matches(r, urls, frame, loader)) + .cloned() + .collect(); + let mut counts = HashMap::new(); + for r in &responses { + *counts.entry(r.url.clone()).or_insert(0) += 1; + } + for r in &mut responses { + r.ambiguous_url = counts[&r.url] > 1; + if r.body.is_none() && r.unavailable_reason.is_none() { + r.unavailable_reason = Some( + if !r.complete { + "response not complete" + } else { + "response metadata or body unavailable" + } + .into(), + ); + } + // An unavailable record must never also expose verified bytes. + if r.unavailable_reason.is_some() { + r.body = None; + } + } + let missing_urls = urls + .iter() + .filter(|u| !counts.contains_key(*u)) + .cloned() + .collect(); + ResponseEvidence { + responses, + missing_urls, + truncated: self.truncated, + changed_during_collection: before != self.revision, + revision: self.revision, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + fn request(t: &mut ResponseCapture, id: &str, url: &str) { + t.event( + "Network.requestWillBeSent", + &json!({"requestId":id,"request":{"url":url},"frameId":"main","loaderId":"current"}), + ); + } + #[test] + fn redirects_cannot_borrow_final_body_and_repeated_urls_remain_ambiguous() { + let mut t = ResponseCapture::default(); + request(&mut t, "1", "asset"); + request(&mut t, "1", "asset"); + t.event("Network.responseReceived",&json!({"requestId":"1","response":{"url":"asset","status":200,"mimeType":"image/png"}})); + t.event("Network.loadingFinished", &json!({"requestId":"1"})); + assert_eq!( + t.pending(&["asset".into()], "main", "current"), + vec![(1, "1".into())] + ); + t.store_body(1, Ok(json!({"body":"AP8=","base64Encoded":true}))); + let e = t.evidence(&["asset".into()], "main", "current", t.revision); + assert!(e.responses.iter().all(|r| r.ambiguous_url)); + assert!(e.responses[0].body.is_none()); + assert_eq!(e.responses[1].body, Some(vec![0, 255])); + } + #[test] + fn incomplete_failed_and_wrong_document_are_explicit() { + let mut t = ResponseCapture::default(); + request(&mut t, "1", "inflight"); + request(&mut t, "2", "failed"); + t.event( + "Network.loadingFailed", + &json!({"requestId":"2","errorText":"blocked"}), + ); + let urls = vec!["inflight".into(), "failed".into(), "absent".into()]; + let e = t.evidence(&urls, "main", "current", 0); + assert!(e.changed_during_collection); + assert!( + e.responses + .iter() + .all(|r| r.body.is_none() && r.unavailable_reason.is_some()) + ); + assert_eq!(e.missing_urls, vec!["absent"]); + assert!( + t.evidence(&urls, "iframe", "current", 0) + .responses + .is_empty() + ); + assert!(t.evidence(&urls, "main", "old", 0).responses.is_empty()); + assert!(t.evidence(&urls, "main", "", 0).responses.is_empty()); + } + #[test] + fn limits_and_invalid_encoding_never_become_empty_verified_bodies() { + let mut t = ResponseCapture::default(); + request(&mut t, "1", "asset"); + t.store_body(0, Ok(json!({"body":"!","base64Encoded":true}))); + assert!(t.records[0].unavailable_reason.is_some()); + request(&mut t, "2", "large"); + t.body_bytes = MAX_TOTAL; + t.store_body(1, Ok(json!({"body":"YQ==","base64Encoded":true}))); + assert!(t.records[1].unavailable_reason.is_some()); + for i in 2..=MAX_RECORDS { + request(&mut t, &i.to_string(), "more"); + } + assert!(t.truncated); + assert_eq!(t.records.len(), MAX_RECORDS); + } +} diff --git a/crates/browser/tests/html_snapshot.rs b/crates/browser/tests/html_snapshot.rs new file mode 100644 index 000000000..59628aa90 --- /dev/null +++ b/crates/browser/tests/html_snapshot.rs @@ -0,0 +1,224 @@ +use impeccable_browser::html_snapshot::{HtmlSnapshot, SnapshotSelection}; +use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "capture-snapshot-{}-{}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(root.join("assets")).unwrap(); + fs::create_dir_all(root.join(".impeccable")).unwrap(); + for (name, bytes) in [ + ("index.html", ""), + ("other.html", "other page"), + ("assets/art.png", "asset bytes"), + (".impeccable/spec.json", "{}"), + (".impeccable/comp.png", "comp bytes"), + (".env", "private"), + ] { + fs::write(root.join(name), bytes).unwrap(); + } + Self(root) + } + fn selection(&self) -> SnapshotSelection { + SnapshotSelection { + root: self.0.clone(), + entry: "index.html".into(), + served: vec!["index.html".into(), "assets/art.png".into()], + bound: vec![ + ".impeccable/spec.json".into(), + ".impeccable/comp.png".into(), + ], + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn snapshot_freezes_bytes_and_detects_every_changed_input() { + let f = Fixture::new(); + let original = HtmlSnapshot::freeze(f.selection()).unwrap(); + let mut reordered = f.selection(); + reordered.served.reverse(); + reordered.bound.reverse(); + assert_eq!( + original.digest(), + HtmlSnapshot::freeze(reordered).unwrap().digest() + ); + for name in [ + "index.html", + "assets/art.png", + ".impeccable/spec.json", + ".impeccable/comp.png", + ] { + let before = fs::read(f.0.join(name)).unwrap(); + fs::write(f.0.join(name), b"changed").unwrap(); + assert!(original.verify_current().is_err(), "{name}"); + assert_eq!(original.bytes(name).unwrap(), before); + fs::write(f.0.join(name), before).unwrap(); + original.verify_current().unwrap(); + } +} + +#[test] +fn routes_are_explicit_and_never_expose_bound_private_files() { + let f = Fixture::new(); + let s = HtmlSnapshot::freeze(f.selection()).unwrap(); + assert_eq!( + s.serve_path("/index.html?cache=2"), + Some("index.html".into()) + ); + assert_eq!( + s.serve_path("/assets/art.png"), + Some("assets/art.png".into()) + ); + for route in [ + "/", + "/other.html", + "/.env", + "/.impeccable/comp.png", + "/../index.html", + "/assets/../index.html", + "/%2e%2e/index.html", + "/assets%2fart.png", + "/assets\\art.png", + "http://example.com/index.html", + ] { + assert!(s.serve_path(route).is_none(), "{route}"); + } + let mut selection = f.selection(); + selection.served.push(".env".into()); + assert!(HtmlSnapshot::freeze(selection).is_err()); +} + +#[test] +fn snapshot_rejects_traversal_symlinks_and_non_html_entries() { + let f = Fixture::new(); + for bad in [ + "../index.html", + "/index.html", + "assets/../index.html", + "assets/art.png", + ] { + let mut selection = f.selection(); + selection.entry = bad.into(); + assert!(HtmlSnapshot::freeze(selection).is_err(), "{bad}"); + } + #[cfg(unix)] + { + std::os::unix::fs::symlink(f.0.join("assets"), f.0.join("linked")).unwrap(); + let mut selection = f.selection(); + selection.served.push("linked/art.png".into()); + assert!(HtmlSnapshot::freeze(selection).is_err()); + let s = HtmlSnapshot::freeze(f.selection()).unwrap(); + fs::rename(f.0.join("assets"), f.0.join("original-assets")).unwrap(); + std::os::unix::fs::symlink(f.0.join("original-assets"), f.0.join("assets")).unwrap(); + assert!(s.verify_current().is_err()); + } +} + +#[test] +fn snapshot_server_serves_frozen_bytes_and_checks_host() { + use std::{ + io::{Read, Write}, + net::TcpStream, + sync::Arc, + }; + let f = Fixture::new(); + let snapshot = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap()); + let server = snapshot.serve().unwrap(); + let url = server.entry_url(); + let host = url + .strip_prefix("http://") + .unwrap() + .split('/') + .next() + .unwrap(); + let get = |path: &str, request_host: &str| { + let mut stream = TcpStream::connect(host).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: {request_host}\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + response + }; + fs::write(f.0.join("assets/art.png"), b"new bytes").unwrap(); + let actual = get("/assets/art.png", host); + assert!(actual.ends_with(b"asset bytes")); + assert!( + String::from_utf8_lossy(&actual) + .contains("Cache-Control: private, max-age=3600, immutable") + ); + assert!(String::from_utf8_lossy(&actual).starts_with("HTTP/1.1 200 OK")); + for (path, h) in [ + ("/.env", host), + ("/.impeccable/spec.json", host), + ("/other.html", host), + ("/index.html", "attacker.example"), + ("/assets/../index.html", host), + ] { + assert!(String::from_utf8_lossy(&get(path, h)).starts_with("HTTP/1.1 404")); + } + assert!(snapshot.verify_current().is_err()); +} + +#[test] +fn snapshot_server_delivers_large_binary_response_completely() { + use std::{ + io::{Read, Write}, + net::TcpStream, + sync::Arc, + }; + let f = Fixture::new(); + let payload: Vec = (0..3 * 1024 * 1024).map(|n| (n % 251) as u8).collect(); + fs::write(f.0.join("assets/art.png"), &payload).unwrap(); + let snapshot = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap()); + let server = snapshot.serve().unwrap(); + let url = server.entry_url(); + let host = url + .strip_prefix("http://") + .unwrap() + .split('/') + .next() + .unwrap(); + let mut stream = TcpStream::connect(host).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + write!( + stream, + "GET /assets/art.png HTTP/1.1\r\nHost: {host}\r\n\r\n" + ) + .unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + let body = response.windows(4).position(|b| b == b"\r\n\r\n").unwrap() + 4; + assert_eq!( + response.len() - body, + payload.len(), + "large response was truncated" + ); + assert_eq!(&response[body..], payload); +} diff --git a/crates/browser/tests/isolated_world.rs b/crates/browser/tests/isolated_world.rs new file mode 100644 index 000000000..3acb74887 --- /dev/null +++ b/crates/browser/tests/isolated_world.rs @@ -0,0 +1,49 @@ +use impeccable_browser::{cdp::Browser, discovery}; +use std::time::Duration; + +#[test] +fn isolated_world_keeps_page_prototypes_and_globals_separate_and_rejects_navigation() { + let env = std::env::vars().collect(); + let Ok(exe) = discovery::find_browser(&env) else { + eprintln!("skip: no browser"); + return; + }; + let mut browser = Browser::launch(&exe, &[], false).unwrap(); + let mut page = browser.new_page().unwrap(); + page.goto("data:text/html,
","load",Duration::from_secs(15)).unwrap(); + page.evaluate_value( + "Element.prototype.getBoundingClientRect=function(){return new DOMRect(999,999,1,1)};true", + ) + .unwrap(); + let world = page.create_isolated_world().unwrap(); + assert_eq!( + page.evaluate_value("document.querySelector('#target').getBoundingClientRect().x") + .unwrap(), + 999 + ); + assert_eq!( + page.evaluate_value_in_world( + &world, + "document.querySelector('#target').getBoundingClientRect().x" + ) + .unwrap(), + 20 + ); + page.evaluate_value_in_world(&world, "globalThis.__capture_secret=42;true") + .unwrap(); + assert_eq!( + page.evaluate_value("typeof globalThis.__capture_secret") + .unwrap(), + "undefined" + ); + page.goto("about:blank", "load", Duration::from_secs(15)) + .unwrap(); + assert!( + page.evaluate_value_in_world(&world, "true").is_err(), + "old world must not follow navigation" + ); + let next = page.create_isolated_world().unwrap(); + assert!(page.evaluate_value_in_world(&next,"setTimeout(()=>location.href='about:blank',0);new Promise(r=>setTimeout(()=>r(true),100))").is_err(),"navigation while awaiting must not return successful evidence"); + page.close(); + browser.close(); +} diff --git a/crates/browser/tests/response_capture.rs b/crates/browser/tests/response_capture.rs new file mode 100644 index 000000000..9c9536c9d --- /dev/null +++ b/crates/browser/tests/response_capture.rs @@ -0,0 +1,105 @@ +//! Native transport evidence: read the response that was rendered, never refetch. +use impeccable_browser::{cdp::Browser, discovery}; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use std::time::Duration; + +#[test] +fn response_capture_reads_original_bytes_and_preserves_repeated_url_ambiguity() { + let env: HashMap = std::env::vars().collect(); + let Ok(exe) = discovery::find_browser(&env) else { + eprintln!("skip: no browser"); + return; + }; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let origin = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + let hits = Arc::new(AtomicUsize::new(0)); + let count = hits.clone(); + std::thread::spawn(move || { + for mut stream in listener.incoming().flatten() { + let mut request = [0u8; 4096]; + let n = stream.read(&mut request).unwrap_or(0); + let request = String::from_utf8_lossy(&request[..n]); + let (kind, body) = if request.starts_with("GET /asset.bin ") { + let number = count.fetch_add(1, Ordering::SeqCst); + ( + "application/octet-stream", + if number == 0 { + vec![0, 255, 13, 128, 42] + } else { + vec![99, 4, 0, 128] + }, + ) + } else { + ( + "text/html", + b"capturefixture".to_vec(), + ) + }; + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(&body); + } + }); + let mut browser = Browser::launch(&exe, &[], false).expect("isolated browser"); + let mut page = browser.new_page().unwrap(); + assert!( + page.response_evidence(&[]).is_err(), + "capture must be explicitly enabled" + ); + page.begin_response_capture().unwrap(); + page.goto(&origin, "load", Duration::from_secs(15)).unwrap(); + page.evaluate_value("fetch('/asset.bin').then(r=>r.arrayBuffer()).then(()=>true)") + .unwrap(); + // Pump a browser round-trip after the fetch's completion event. + page.evaluate_value("true").unwrap(); + let urls = vec![format!("{origin}/asset.bin")]; + let first = page.response_evidence(&urls).unwrap(); + assert_eq!(first.responses.len(), 1); + assert_eq!( + first.responses[0].body.as_deref(), + Some(&[0, 255, 13, 128, 42][..]) + ); + assert!(!first.responses[0].ambiguous_url); + assert_eq!( + hits.load(Ordering::SeqCst), + 1, + "body evidence must not make another request" + ); + page.evaluate_value("fetch('/asset.bin').then(r=>r.arrayBuffer()).then(()=>true)") + .unwrap(); + page.evaluate_value("true").unwrap(); + let repeated = page.response_evidence(&urls).unwrap(); + assert_eq!(repeated.responses.len(), 2); + assert!(repeated.responses.iter().all(|r| r.ambiguous_url)); + assert_eq!( + repeated.responses[0].body.as_deref(), + Some(&[0, 255, 13, 128, 42][..]) + ); + assert_eq!( + repeated.responses[1].body.as_deref(), + Some(&[99, 4, 0, 128][..]) + ); + assert_eq!(hits.load(Ordering::SeqCst), 2); + assert!(!repeated.changed_during_collection); + assert!(!repeated.truncated); + assert_eq!(repeated.responses[0].status, Some(200.0)); + assert_eq!(repeated.responses[0].mime_type, "application/octet-stream"); + // A new document must not borrow response evidence from its predecessor. + page.goto(&format!("{origin}/next"), "load", Duration::from_secs(15)) + .unwrap(); + let next = page.response_evidence(&urls).unwrap(); + assert!(next.responses.is_empty()); + assert_eq!(next.missing_urls, urls); + assert_eq!(hits.load(Ordering::SeqCst), 2); + page.close(); + browser.close(); +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 2a8b24f42..aceb00c16 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -19,6 +19,7 @@ impeccable-comp = { workspace = true } impeccable-comp-verbs = { workspace = true } serde_json = { workspace = true } base64 = "0.22" +sha2 = "0.10" [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/cli/examples/capture_asset.rs b/crates/cli/examples/capture_asset.rs new file mode 100644 index 000000000..da71ed24b --- /dev/null +++ b/crates/cli/examples/capture_asset.rs @@ -0,0 +1,39 @@ +//! Offline diagnostic driver; not a production CLI verb or approval authority. +//! cargo run -p impeccable --example capture_asset -- request.json new-output-dir +use impeccable::asset_capture::CdpAssetRenderer; +use impeccable_comp_verbs::asset_capture::{AssetCaptureRequest, AssetRenderer}; +use serde_json::Value; +use std::{fs, path::Path}; +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + if args.len() != 3 { + return Err("expected request.json and a new output directory".into()); + } + let config: Value = serde_json::from_slice(&fs::read(&args[1])?)?; + let text = |key: &str| config[key].as_str().ok_or_else(|| format!("missing {key}")); + let request = AssetCaptureRequest { + url: text("url")?.into(), + viewport: serde_json::from_value(config["viewport"].clone())?, + reduced_motion: config["reducedMotion"] + .as_bool() + .ok_or("missing reducedMotion")?, + expected_box: serde_json::from_value(config["expectedBox"].clone())?, + reference_bytes: fs::read(text("referencePath")?)?, + asset_bytes: fs::read(text("assetPath")?)?, + }; + let result = CdpAssetRenderer::from_process_env().capture(&request)?; + let out = Path::new(&args[2]); + fs::create_dir(out)?; + for image in result.images { + fs::write(out.join(image.name), image.png)?; + } + fs::write( + out.join("receipt.json"), + serde_json::to_vec_pretty(&result.receipt)?, + )?; + println!( + "{}", + serde_json::json!({"status":result.receipt["status"],"reason":result.receipt["reason"],"output":out}) + ); + Ok(()) +} diff --git a/crates/cli/examples/capture_service_request.rs b/crates/cli/examples/capture_service_request.rs new file mode 100644 index 000000000..c57009924 --- /dev/null +++ b/crates/cli/examples/capture_service_request.rs @@ -0,0 +1,14 @@ +//! Offline HTTP client without filesystem/TLS configuration dependencies. +use std::{io::{Read,Write},net::TcpStream,time::Duration}; +fn main()->Result<(),Box>{ + let args:Vec<_>=std::env::args().collect(); + if args.len()!=5{return Err("port route capability body".into());} + let port:u16=args[1].parse()?; + let mut stream=TcpStream::connect(("127.0.0.1",port))?; + stream.set_read_timeout(Some(Duration::from_secs(90)))?; + let body=&args[4]; + write!(stream,"POST {} HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Capture-Key: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",args[2],args[3],body.len(),body)?; + let mut bytes=Vec::new();stream.take(64*1024*1024+8192).read_to_end(&mut bytes)?; + let split=bytes.windows(4).position(|v|v==b"\r\n\r\n").ok_or("invalid HTTP")?; + std::io::stdout().write_all(&bytes[split+4..])?;Ok(()) +} diff --git a/crates/cli/examples/capture_service_roundtrip.rs b/crates/cli/examples/capture_service_roundtrip.rs new file mode 100644 index 000000000..814fc8d91 --- /dev/null +++ b/crates/cli/examples/capture_service_roundtrip.rs @@ -0,0 +1,17 @@ +//! Offline transport client: records the same audit files as the shared native gate. +use impeccable::capture_service::RemoteEntryRenderer; +use impeccable_comp_verbs::entry_capture::{EntryRenderer,EntryRequest,EntryStage}; +use std::{fs,path::PathBuf}; +fn main()->Result<(),Box>{ + let stage=std::env::args().nth(1).unwrap_or_else(||"hero".into()); + let renderer=RemoteEntryRenderer::from_env(&std::env::vars().collect())?.ok_or("service not configured")?; + let capture=renderer.capture_entry(&EntryRequest{root:std::env::current_dir()?,artifact:"index.html".into(),spec:"spec.json".into(),reference:"comp.png".into(),stage:if stage=="responsive"{EntryStage::Responsive}else{EntryStage::Hero}})?; + let base=PathBuf::from(format!(".impeccable/review/native/{stage}"));fs::create_dir_all(&base)?; + fs::write(base.join("inputs.json"),serde_json::to_vec(&capture.evidence().report)?)?; + for f in &capture.evidence().frames{ + fs::write(base.join(format!("{}.png",f.name)),&f.png)?; + let observations:Vec<_>=f.regions.iter().map(|r|r.receipt.clone()).collect(); + fs::write(base.join(format!("{}-observations.json",f.name)),serde_json::to_vec(&observations)?)?; + } + capture.verify_current()?; println!("{}",serde_json::json!({"status":"captured","id":capture.evidence().report["captureService"]["id"]}));Ok(()) +} diff --git a/crates/cli/examples/capture_snapshot.rs b/crates/cli/examples/capture_snapshot.rs new file mode 100644 index 000000000..180d43cd1 --- /dev/null +++ b/crates/cli/examples/capture_snapshot.rs @@ -0,0 +1,60 @@ +//! Offline diagnostic using a frozen static entry. Not a production CLI verb. +//! cargo run -p impeccable --example capture_snapshot -- request.json new-output-dir +use impeccable::{ + asset_capture::CdpAssetRenderer, + capture_snapshot::{HtmlSnapshot, SnapshotSelection}, +}; +use serde_json::Value; +use std::{fs, path::Path, sync::Arc}; +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + if args.len() != 3 { + return Err("expected request.json and a new output directory".into()); + } + let config: Value = serde_json::from_slice(&fs::read(&args[1])?)?; + let text = |key: &str| config[key].as_str().ok_or_else(|| format!("missing {key}")); + let snapshot = Arc::new(HtmlSnapshot::freeze(SnapshotSelection { + root: text("root")?.into(), + entry: text("entry")?.into(), + served: serde_json::from_value(config["served"].clone())?, + bound: serde_json::from_value(config["bound"].clone())?, + })?); + let regions: Vec = if config["regions"].is_array() { + serde_json::from_value(config["regions"].clone())? + } else { + vec![text("region")?.into()] + }; + let captures = snapshot.capture_regions( + &mut CdpAssetRenderer::from_process_env(), + text("spec")?, + ®ions.iter().map(String::as_str).collect::>(), + config["reducedMotion"] + .as_bool() + .ok_or("missing reducedMotion")?, + )?; + let out = Path::new(&args[2]); + fs::create_dir(out)?; + let mut summaries = Vec::new(); + for (index, capture) in captures.into_iter().enumerate() { + let destination = if regions.len() == 1 { + out.to_path_buf() + } else { + let p = out.join(format!("region-{index}")); + fs::create_dir(&p)?; + p + }; + for image in capture.images { + fs::write(destination.join(image.name), image.png)?; + } + fs::write( + destination.join("receipt.json"), + serde_json::to_vec_pretty(&capture.receipt)?, + )?; + summaries.push(serde_json::json!({"region":regions[index],"status":capture.receipt["status"],"reason":capture.receipt["reason"],"output":destination})); + } + println!( + "{}", + serde_json::json!({"snapshot":snapshot.digest(),"regions":summaries}) + ); + Ok(()) +} diff --git a/crates/cli/examples/capture_snapshot_viewport.rs b/crates/cli/examples/capture_snapshot_viewport.rs new file mode 100644 index 000000000..5c9345a87 --- /dev/null +++ b/crates/cli/examples/capture_snapshot_viewport.rs @@ -0,0 +1,61 @@ +//! Offline diagnostic using a frozen static entry. Not a production CLI verb. +//! cargo run -p impeccable --example capture_snapshot -- request.json new-output-dir +use impeccable::{ + asset_capture::CdpAssetRenderer, + capture_snapshot::{HtmlSnapshot, SnapshotSelection}, +}; +use serde_json::Value; +use std::{fs, path::Path, sync::Arc}; +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + if args.len() != 3 { + return Err("expected request.json and a new output directory".into()); + } + let config: Value = serde_json::from_slice(&fs::read(&args[1])?)?; + let text = |key: &str| config[key].as_str().ok_or_else(|| format!("missing {key}")); + let snapshot = Arc::new(HtmlSnapshot::freeze(SnapshotSelection { + root: text("root")?.into(), + entry: text("entry")?.into(), + served: serde_json::from_value(config["served"].clone())?, + bound: serde_json::from_value(config["bound"].clone())?, + })?); + let regions: Vec = if config["regions"].is_array() { + serde_json::from_value(config["regions"].clone())? + } else { + vec![text("region")?.into()] + }; + let captures = snapshot.capture_regions_at_viewport( + &mut CdpAssetRenderer::from_process_env(), + text("spec")?, + ®ions.iter().map(String::as_str).collect::>(), + config["reducedMotion"] + .as_bool() + .ok_or("missing reducedMotion")?, + serde_json::from_value(config["viewport"].clone()).ok(), + )?; + let out = Path::new(&args[2]); + fs::create_dir(out)?; + let mut summaries = Vec::new(); + for (index, capture) in captures.into_iter().enumerate() { + let destination = if regions.len() == 1 { + out.to_path_buf() + } else { + let p = out.join(format!("region-{index}")); + fs::create_dir(&p)?; + p + }; + for image in capture.images { + fs::write(destination.join(image.name), image.png)?; + } + fs::write( + destination.join("receipt.json"), + serde_json::to_vec_pretty(&capture.receipt)?, + )?; + summaries.push(serde_json::json!({"region":regions[index],"status":capture.receipt["status"],"reason":capture.receipt["reason"],"output":destination})); + } + println!( + "{}", + serde_json::json!({"snapshot":snapshot.digest(),"regions":summaries}) + ); + Ok(()) +} diff --git a/crates/cli/examples/entry_capture_service.rs b/crates/cli/examples/entry_capture_service.rs new file mode 100644 index 000000000..cc6c81087 --- /dev/null +++ b/crates/cli/examples/entry_capture_service.rs @@ -0,0 +1,70 @@ +//! Offline prototype: one registered root, shared native capture/verify/release. +//! Not a shipped CLI verb or an eval approval endpoint. +use base64::Engine; +use impeccable::entry_capture::CdpEntryRenderer; +use impeccable_comp_verbs::entry_capture::{CapturedEntry, EntryRenderer, EntryRequest, EntryStage}; +use serde_json::{json, Value}; +use std::{collections::HashMap, io::{BufRead, BufReader, Read, Write}, net::{TcpListener, TcpStream}, path::PathBuf, time::{Duration, Instant}}; + +fn request(stream: &TcpStream, key: &str) -> Result<(String, Value), String> { + let mut reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?); + let mut first = String::new(); reader.by_ref().take(1025).read_line(&mut first).map_err(|e|e.to_string())?; + if first.len() > 1024 { return Err("request line too long".into()); } + let parts: Vec<_> = first.split_whitespace().collect(); + if parts.len()!=3 || parts[0]!="POST" { return Err("POST required".into()); } + let route=parts[1].to_string(); let mut total=first.len(); let mut length=None; let mut authenticated=false; + loop { + let mut line=String::new(); reader.by_ref().take(8193).read_line(&mut line).map_err(|e|e.to_string())?; + total+=line.len(); if total>8192 || line.is_empty() {return Err("invalid headers".into());} + if line=="\r\n" || line=="\n" {break;} + let (name,value)=line.split_once(':').ok_or("invalid header")?; + match name.to_ascii_lowercase().as_str() { + "content-length"=>{if length.is_some(){return Err("duplicate length".into());} length=Some(value.trim().parse::().map_err(|_|"invalid length")?);}, + "x-capture-key"=>authenticated=value.trim()==key, + "origin"|"transfer-encoding"=>return Err("unsupported request origin/encoding".into()), + _=>{} + } + } + if !authenticated {return Err("invalid capability".into());} + let length=length.ok_or("missing length")?;if length>16384{return Err("request too large".into());} + let mut body=vec![0;length];reader.read_exact(&mut body).map_err(|e|e.to_string())?; + let body:Value=serde_json::from_slice(&body).map_err(|e|e.to_string())?; + if !body.is_object() || body.get("root").is_some() || body.get("url").is_some(){return Err("registered root only; no caller URLs".into());} + Ok((route,body)) +} +fn main()->Result<(),Box> { + let args:Vec<_>=std::env::args().collect();if args.len()!=3{return Err("expected registered-root ready-file".into());} + let root=std::fs::canonicalize(&args[1])?;let key=std::env::var("IMPECCABLE_CAPTURE_CAPABILITY")?; + if key.len()<32{return Err("capability too short".into());} + let listener=TcpListener::bind("127.0.0.1:0")?;listener.set_nonblocking(true)?; + std::fs::write(&args[2],serde_json::to_vec(&json!({"port":listener.local_addr()?.port(),"root":root}))?)?; + let started=Instant::now();let mut serial=0u64;let mut captures:HashMap)>=HashMap::new(); + while started.elapsed()v,Err(e) if e.kind()==std::io::ErrorKind::WouldBlock=>{std::thread::sleep(Duration::from_millis(10));continue;},Err(e)=>return Err(e.into())}; + stream.set_nonblocking(false)?;stream.set_read_timeout(Some(Duration::from_secs(5)))?;stream.set_write_timeout(Some(Duration::from_secs(10)))?; + let answer=(||->Result{ + let (route,body)=request(&stream,&key)?; + let text=|k:&str|body[k].as_str().ok_or_else(||format!("missing {k}")); + match route.as_str() { + "/capture"=>{ + if captures.len()>=2{return Err("active capture limit".into());} + let stage=match text("stage")?{"hero"=>EntryStage::Hero,"responsive"=>EntryStage::Responsive,_=>return Err("invalid stage".into())}; + let captured=CdpEntryRenderer.capture_entry(&EntryRequest{root:PathBuf::from(&root),artifact:text("entry")?.into(),spec:text("spec")?.into(),reference:text("reference")?.into(),stage})?; + let evidence=captured.evidence(); + let frames:Vec<_>=evidence.frames.iter().map(|f|json!({"name":f.name,"png":base64::engine::general_purpose::STANDARD.encode(&f.png),"regions":f.regions.iter().map(|r|r.receipt.clone()).collect::>()})).collect(); + serial+=1;let handle=format!("capture-{serial}");let response=json!({"ok":true,"handle":handle,"report":evidence.report,"frames":frames}); + if serde_json::to_vec(&response).map_err(|e|e.to_string())?.len()>64*1024*1024{return Err("response budget exceeded".into());} + captures.insert(handle,(Instant::now(),captured));Ok(response) + }, + "/verify"=>{let (_,capture)=captures.get(text("handle")?).ok_or("unknown capture")?;capture.verify_current()?;Ok(json!({"ok":true}))}, + "/release"=>Ok(json!({"ok":captures.remove(text("handle")?).is_some()})), + _=>Err("unknown operation".into()) + } + })(); + let (status,body)=match answer{Ok(v)=>(200,v),Err(e)=>(400,json!({"ok":false,"error":e}))}; + let bytes=serde_json::to_vec(&body)?;let header=format!("HTTP/1.1 {status} Result\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",bytes.len()); + let _=stream.write_all(header.as_bytes()).and_then(|_|stream.write_all(&bytes)); + } + Ok(()) +} diff --git a/crates/cli/src/asset_capture.js b/crates/cli/src/asset_capture.js new file mode 100644 index 000000000..86a08697b --- /dev/null +++ b/crates/cli/src/asset_capture.js @@ -0,0 +1,97 @@ +(key) => { + if (Object.hasOwn(globalThis,key)) throw Error('capture key collision'); + const nodes=[...document.querySelectorAll('*')]; + if(nodes.length>5000) throw Error('capture DOM exceeds 5000 elements'); + const box=el=>{const b=el.getBoundingClientRect();return {x:b.x,y:b.y,w:b.width,h:b.height};}; + // Computed CSS only. Split complete top-level layers, preserving quoted URL commas. + const layers=value=>{ + const out=[];let start=0,depth=0,quote=null,escaped=false; + for(let i=0;i16)return null; + return out.every(v=>v==='none'||/^url\("([^"\\]*)"\)$/.test(v)||(/^(repeating-)?(linear|radial|conic)-gradient\(/.test(v)&&!/(url|image-set|paint)\(/.test(v)))?out:null; + }; + const state={active:null,pseudos:[]}; + const computed=s=>({display:s.display,visibility:s.visibility,opacity:s.opacity,objectFit:s.objectFit,objectPosition:s.objectPosition,backgroundSize:s.backgroundSize,backgroundPosition:s.backgroundPosition}); + state.scan=()=>{ + const current=[...document.querySelectorAll('*')]; + const sameNodes=current.length===nodes.length&¤t.every((el,i)=>el===nodes[i]); + const rows=[],unsupported=[]; + for(const [index,el] of current.entries()){ + const s=getComputedStyle(el),b=box(el); + if(el.shadowRoot||['IFRAME','FRAME','CANVAS','VIDEO','SVG'].includes(el.tagName.toUpperCase()))unsupported.push({kind:el.shadowRoot?'shadow-root':el.tagName.toLowerCase(),box:b}); + const ancestors=[]; + for(let p=el.parentElement;p;p=p.parentElement){const c=getComputedStyle(p);if(c.opacity!=='1'||c.visibility!=='visible'||c.display==='none'||c.overflow!=='visible'||c.clipPath!=='none')ancestors.push({tag:p.tagName,opacity:c.opacity,visibility:c.visibility,display:c.display,overflow:c.overflow,clipPath:c.clipPath});} + if(el instanceof HTMLImageElement&&el.currentSrc)rows.push({kind:'img',url:el.currentSrc,decoded:el.complete&&el.naturalWidth>0,supported:true,index,tag:el.tagName,elementId:el.id,box:b,computed:computed(s),ancestors}); + for(const pseudo of ['', '::before','::after']){ + const p=pseudo?getComputedStyle(el,pseudo):s; + const bg=p.backgroundImage; + const native=pseudo?state.pseudos.find(r=>r.index===index&&r.pseudo===pseudo):null; + const bounds=pseudo?native?.box:b; + const parts=layers(bg); + const urls=parts?.map((part,layer)=>({match:/^url\("([^"\\]*)"\)$/.exec(part),layer})).filter(r=>r.match)||[]; + const unknown=bg!=='none'&&(!parts||(pseudo&&!urls.length)||(pseudo&&!bounds)); + if(unknown||(pseudo&&p.content.includes('url('))){unsupported.push({kind:pseudo?'pseudo-imagery':'complex-background',box:bounds||b});continue;} + for(const r of urls)rows.push({kind:pseudo?'pseudo-background':'background',pseudo,layer:r.layer,url:new URL(r.match[1],location.href).href,decoded:null,supported:true,index,tag:el.tagName,elementId:el.id,box:bounds,computed:computed(p),ancestors:pseudo?[{tag:el.tagName,...computed(s)},...ancestors]:ancestors}); + } + } + return {sameNodes,dom:document.documentElement.outerHTML,url:location.href, + viewport:{width:innerWidth,height:innerHeight,dpr:devicePixelRatio,scrollX,scrollY}, + layout:current.map(box),pseudoLayout:state.pseudos,rows,unsupported, + runningAnimations:document.getAnimations().filter(a=>a.playState==='running').length}; + }; + // Our temporary changes can start authored transitions. Let them finish naturally. + state.settle=()=>Promise.race([ + (async()=>{await Promise.all(document.getAnimations().map(a=>a.finished));await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));return true;})(), + new Promise((_,reject)=>setTimeout(()=>reject(Error('capture intervention did not settle within 1s')),1000)) + ]); + state.suppressMany=(items)=>{ + if(state.active)throw Error('capture intervention already active'); + state.active={styles:new Map(),expected:[]};const groups=new Map(),rules=[]; + for(const item of items){ + const {index,kind}=item,el=nodes[index]; + if(!el||el!==document.querySelectorAll('*')[index])throw Error('capture node changed'); + if(kind!=='pseudo-background'&&!state.active.styles.has(el))state.active.styles.set(el,el.getAttribute('style')); + if(kind==='img'){ + const nw=el.naturalWidth,nh=el.naturalHeight; + const distance=Math.ceil(Math.max(nw,nh,el.clientWidth,el.clientHeight)*Math.max(1,el.clientWidth/nw,el.clientHeight/nh)+innerWidth+innerHeight+1); + if(!Number.isFinite(distance)||distance>10000000)throw Error('image displacement exceeds capture coordinate budget'); + el.style.setProperty('object-position',`${distance}px ${distance}px`,'important'); + }else{ + const pseudo=item.pseudo||'',id=index+pseudo; + if(!groups.has(id))groups.set(id,{index,pseudo,parts:layers(getComputedStyle(el,pseudo||null).backgroundImage),remove:new Set()}); + const group=groups.get(id); + if(!group.parts||!Number.isInteger(item.layer)||!/^url\(/.test(group.parts[item.layer]||''))throw Error('capture background layer changed'); + group.remove.add(item.layer); + } + } + for(const group of groups.values()){ + const image=group.parts.map((v,i)=>group.remove.has(i)?'none':v).join(', '); + const el=nodes[group.index]; + if(group.pseudo){ + const native=state.pseudos.find(r=>r.index===group.index&&r.pseudo===group.pseudo); + if(!native?.box)throw Error('capture pseudo geometry unavailable'); + rules.push(`${native.selector}${group.pseudo}{background-image:${image}!important}`); + }else el.style.setProperty('background-image',image,'important'); + state.active.expected.push({index:group.index,pseudo:group.pseudo,image}); + } + return {dom:document.documentElement.outerHTML,layout:state.scan().layout,rules:rules.join('\n')}; + }; + state.verifySuppression=()=>{ + for(const e of state.active?.expected||[]){if(getComputedStyle(nodes[e.index],e.pseudo||null).backgroundImage!==e.image)throw Error('capture background suppression was overridden');} + return true; + }; + state.restore=()=>{ + if(state.active){for(const [el,style] of state.active.styles)style===null?el.removeAttribute('style'):el.setAttribute('style',style);state.active=null;} + }; + Object.defineProperty(globalThis,key,{value:state,configurable:true}); + return true; +} diff --git a/crates/cli/src/asset_capture.rs b/crates/cli/src/asset_capture.rs new file mode 100644 index 000000000..bc9d8288c --- /dev/null +++ b/crates/cli/src/asset_capture.rs @@ -0,0 +1,740 @@ +//! Diagnostic asset capture over isolated Chromium. No approval policy lives here. +use base64::Engine; +use impeccable_browser::{ + cdp::{Browser, IsolatedWorld, Page, Viewport}, + discovery, + response_capture::ResponseEvidence, +}; +use impeccable_comp::{png_io, raster::Image}; +use impeccable_comp_verbs::asset_capture::{ + AssetCapture, AssetCaptureRequest, AssetRenderer, CaptureImage, capture_sha256 as hash, +}; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +pub struct CdpAssetRenderer { + env: HashMap, +} +impl CdpAssetRenderer { + pub fn from_process_env() -> Self { + Self { + env: std::env::vars().collect(), + } + } +} +impl AssetRenderer for CdpAssetRenderer { + fn capture(&mut self, request: &AssetCaptureRequest) -> Result { + self.capture_batch(std::slice::from_ref(request))? + .into_iter() + .next() + .ok_or_else(|| "capture returned no evidence".into()) + } + fn capture_batch( + &mut self, + requests: &[AssetCaptureRequest], + ) -> Result, String> { + if requests.is_empty() || requests.len() > 32 { + return Err("capture batch must contain 1 to 32 regions".into()); + } + let request = &requests[0]; + for item in requests { + item.validate()?; + if item.url != request.url + || item.viewport != request.viewport + || item.reduced_motion != request.reduced_motion + || item.reference_bytes != request.reference_bytes + { + return Err( + "capture batch requires one URL, viewport, reference and motion setting".into(), + ); + } + } + let exe = discovery::find_browser(&self.env) + .map_err(|e| format!("browser unavailable: {e:?}"))?; + // Image suppression must restore exact pixels. GPU tile rasterization can + // round resampled pixels differently after an otherwise identical repaint. + // Disable partial raster too: reusing invalidated tiles can change antialiasing + // even with software rasterization. Never substitute a pixel tolerance. + let mut browser = Browser::launch(&exe, &["--disable-gpu-rasterization".into(), "--disable-partial-raster".into()], false) + .map_err(|e| e.message)?; + let browser_version = browser.version().map_err(|e| e.message)?; + let result = (|| { + let mut page = browser.new_page().map_err(|e| e.message)?; + page.set_viewport(Viewport { + width: request.viewport[0], + height: request.viewport[1], + }) + .map_err(|e| e.message)?; + page.set_reduced_motion(request.reduced_motion) + .map_err(|e| e.message)?; + page.begin_response_capture().map_err(|e| e.message)?; + page.goto(&request.url, "load", Duration::from_secs(30)) + .map_err(|e| e.message)?; + let world = page.create_isolated_world().map_err(|e| e.message)?; + let stylesheet = page.create_capture_stylesheet(&world).map_err(|e| e.message)?; + let mut capture = CapturePage { + page: &mut page, + world, + started: Instant::now(), + stylesheet, + }; + // A timeout is an error, never readiness. No resource is fetched again. + eval( + &mut capture, + r#"Promise.race([(async()=>{await document.fonts.ready;await Promise.all([...document.images].filter(i=>{const b=i.getBoundingClientRect();return b.width>0&&b.height>0&&b.x0&&b.bottom>0;}).map(i=>i.decode().catch(()=>{})));return true;})(),new Promise((_,reject)=>setTimeout(()=>reject(Error('capture resources did not settle')),5000))])"#, + )?; + let key = format!( + "__impeccable_capture_{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let key = serde_json::to_string(&key).unwrap(); + eval( + &mut capture, + &format!("({})({key})", include_str!("asset_capture.js")), + )?; + let result: Result, String> = (|| { + let mut attempts = Vec::new(); + let mut anchor: Option = None; + for attempt in 1..=3 { + let mut results = Vec::new(); + for item in requests { + results.push(capture_page(&mut capture, item, &key)?); + } + // Every region must belong to the same settled document and pixels. + let first = &results[0].receipt; + let identity = |r: &Value| { + json!([r["domSha256"], r["captureDocument"], r["networkRevision"]]) + }; + if anchor.is_none() { + anchor = Some(identity(first)); + } + let same_identity = results.iter().all(|r| { + ["domSha256", "captureDocument", "networkRevision"] + .iter() + .all(|k| !r.receipt[*k].is_null()) + && Some(identity(&r.receipt)) == anchor + }); + let stable = same_identity + && results.iter().all(|r| { + r.receipt["stableCapture"] == true + && [ + "domSha256", + "screenshotSha256", + "captureDocument", + "networkRevision", + ] + .iter() + .all(|k| r.receipt[*k] == first[*k]) + }); + attempts.push(json!({"attempt":attempt,"stable":stable, + "regions":results.iter().map(|r|json!({"status":r.receipt["status"],"reason":r.receipt["reason"], + "screenshotSha256":r.receipt["screenshotSha256"],"domSha256":r.receipt["domSha256"],"networkRevision":r.receipt["networkRevision"]})).collect::>() })); + // Style interventions can invalidate Chromium's raster cache. + // Retry the whole batch on the same frozen page; never mix regions + // across attempts or relax exact DOM/pixel/network restoration. + let raster_retry = same_identity + && results + .iter() + .any(|r| r.receipt["retryableRasterInvalidation"] == true) + && results.iter().all(|r| { + r.receipt["stableCapture"] == true + || r.receipt["retryableRasterInvalidation"] == true + }); + if !stable && raster_retry && attempt < 3 { + continue; + } + let complete = + stable && results.iter().all(|r| r.receipt["status"] == "captured"); + for r in &mut results { + r.receipt["batchStabilityVerified"] = json!(stable); + r.receipt["batchAttempts"] = json!(attempts); + } + if !complete && requests.len() > 1 { + results = results + .into_iter() + .map(|mut r| { + r.receipt["individualCaptureStatus"] = r.receipt["status"].clone(); + r.receipt["individualCaptureReason"] = r.receipt["reason"].clone(); + unavailable( + r, + if stable {"batch has incomplete surface coverage; stable known-raster observations retained"} else {"batch did not retain one stable document"}, + ) + }) + .collect(); + } + return Ok(results); + } + unreachable!("bounded capture loop always returns its final attempt") + })(); + // Restore even on a failed screenshot/evaluation, then destroy the isolated page. + let _ = capture.set_stylesheet(""); + let _ = eval( + &mut capture, + &format!( + "(()=>{{globalThis[{key}]?.restore();delete globalThis[{key}];return true;}})()" + ), + ); + drop(capture); + page.close(); + result + })(); + browser.close(); + result.map(|captures| { + captures + .into_iter() + .map(|mut capture| { + capture.receipt["browser"] = browser_version.clone(); + capture.receipt["rasterization"] = json!("software"); + capture.receipt["partialRaster"] = json!(false); + capture.receipt["nativeVersion"] = json!(env!("CARGO_PKG_VERSION")); + capture.receipt["batchSize"] = json!(requests.len()); + capture + }) + .collect() + }) + } +} +struct CapturePage<'p, 'b> { + page: &'p mut Page<'b>, + world: IsolatedWorld, + started: Instant, + stylesheet: String, +} +impl<'p, 'b> std::ops::Deref for CapturePage<'p, 'b> { + type Target = Page<'b>; + fn deref(&self) -> &Self::Target { + self.page + } +} +impl<'p, 'b> std::ops::DerefMut for CapturePage<'p, 'b> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.page + } +} + +impl CapturePage<'_, '_> { + fn set_stylesheet(&mut self, text: &str) -> Result<(), String> { + self.check_budget()?; + self.page.set_capture_stylesheet(&self.world, &self.stylesheet, text).map_err(|e| e.message) + } + fn check_budget(&self) -> Result<(), String> { + if self.started.elapsed() > Duration::from_secs(60) { + Err("capture exceeded 60-second command-boundary budget".into()) + } else { + Ok(()) + } + } + fn coverage(&mut self) -> Result { + self.check_budget()?; + self.page + .capture_dom_coverage(&self.world) + .map_err(|e| e.message) + } +} +fn eval(page: &mut CapturePage<'_, '_>, js: &str) -> Result { + page.check_budget()?; + page.page + .evaluate_value_in_world(&page.world, js) + .map_err(|e| e.message) +} +fn scan(page: &mut CapturePage<'_, '_>, key: &str) -> Result { + let retained = eval(page, &format!("(globalThis[{key}].active?.expected||[]).filter(r=>r.pseudo).map(r=>({{index:r.index,pseudo:r.pseudo.slice(2)}}))"))?; + let retained = retained.as_array().ok_or("capture pseudo identities unavailable")?; + let pseudos = page.page.capture_pseudo_geometry(&page.world, retained).map_err(|e| e.message)?; + eval(page, &format!("(()=>{{globalThis[{key}].pseudos={pseudos};return globalThis[{key}].scan();}})()")) +} +fn screenshot( + page: &mut CapturePage<'_, '_>, + r: &AssetCaptureRequest, +) -> Result<(Vec, Image), String> { + let encoded = page.screenshot_viewport().map_err(|e| e.message)?; + let png = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|e| e.to_string())?; + let image = png_io::decode_png(&png) + .map_err(|e| format!("screenshot decode: {e:?}"))? + .image; + if image.width != r.viewport[0] as usize || image.height != r.viewport[1] as usize { + return Err("screenshot dimensions differ from viewport".into()); + } + Ok((png, image)) +} +fn overlaps(b: &Value, r: &AssetCaptureRequest) -> bool { + let v = |k: &str| b[k].as_f64().unwrap_or(0.); + let e = r.expected_box; + v("w") > 0. + && v("h") > 0. + && v("x") < e.x + e.w + && v("y") < e.y + e.h + && v("x") + v("w") > e.x + && v("y") + v("h") > e.y +} +fn urls(s: &Value) -> Vec { + let mut result = Vec::new(); + for row in s["rows"].as_array().into_iter().flatten() { + if let Some(u) = row["url"].as_str() { + if !result.iter().any(|v| v == u) { + result.push(u.to_owned()); + } + } + } + result +} +fn evidence(page: &mut CapturePage<'_, '_>, urls: &[String]) -> Result { + page.response_evidence(urls).map_err(|e| e.message) +} +fn stable_network(e: &ResponseEvidence, revision: u64) -> bool { + !e.truncated && !e.changed_during_collection && e.revision == revision +} +fn state_reason(s: &Value, r: &AssetCaptureRequest) -> Option { + if s["sameNodes"] != true { + return Some("DOM nodes changed".into()); + } + if s["runningAnimations"].as_u64().unwrap_or(1) > 0 { + return Some("running page animation".into()); + } + let v = &s["viewport"]; + if v["width"] != r.viewport[0] + || v["height"] != r.viewport[1] + || v["dpr"] != 1. + || v["scrollX"] != 0. + || v["scrollY"] != 0. + { + return Some("viewport, scale or scroll changed".into()); + } + None +} +fn receipt(r: &AssetCaptureRequest) -> Value { + json!({ + "schema":"native-asset-capture-diagnostic-v2","inspectionWorld":"isolated","status":"unavailable","requestedUrl":r.url,"reducedMotion":r.reduced_motion, + "viewport":{"width":r.viewport[0],"height":r.viewport[1],"dpr":1},"expectedBox":r.expected_box, + "referenceSha256":hash(&r.reference_bytes),"assetSha256":hash(&r.asset_bytes), + "adequateVisibility":"not-assessed","instances":[],"scope":"Diagnostic evidence only. Observed identity, geometry and paint contribution do not establish fidelity, adequate visibility or generation provenance." + }) +} +fn unavailable(mut out: AssetCapture, reason: impl Into) -> AssetCapture { + out.receipt["status"] = json!("unavailable"); + out.receipt["reason"] = json!(reason.into()); + out +} +fn static_raster(bytes: &[u8]) -> bool { + if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + return true; + } + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return false; + } + // APNG has an acTL chunk. Read chunk boundaries, not incidental payload text. + let mut p = 8; + while p + 12 <= bytes.len() { + let len = u32::from_be_bytes(bytes[p..p + 4].try_into().unwrap()) as usize; + if &bytes[p + 4..p + 8] == b"acTL" { + return false; + } + let Some(next) = p.checked_add(12).and_then(|x| x.checked_add(len)) else { + return false; + }; + if next > bytes.len() { + return false; + } + if &bytes[p + 4..p + 8] == b"IEND" { + return true; + } + p = next; + } + false +} +fn changed_pixels(a: &Image, b: &Image, r: &AssetCaptureRequest) -> (u64, u64) { + let e = r.expected_box; + let mut region = 0; + let mut total = 0; + for y in 0..a.height { + for x in 0..a.width { + let p = (y * a.width + x) * 4; + if a.data[p..p + 4] != b.data[p..p + 4] { + total += 1; + // Pixel centers define the fixed reference-owned sampling region. + if x as f64 + 0.5 >= e.x + && x as f64 + 0.5 < e.x + e.w + && y as f64 + 0.5 >= e.y + && y as f64 + 0.5 < e.y + e.h + { + region += 1; + } + } + } + } + (region, total) +} +fn capture_page( + page: &mut CapturePage<'_, '_>, + r: &AssetCaptureRequest, + key: &str, +) -> Result { + let mut out = AssetCapture { + receipt: receipt(r), + images: Vec::new(), + }; + let coverage = page.coverage()?; + out.receipt["domCoverage"] = coverage.clone(); + out.receipt["limits"] = + json!({"nativeDomNodes":5000,"matchingInstances":16,"commandBoundaryBudgetSeconds":60}); + if coverage["closedShadowRoots"].as_u64().unwrap_or(1) > 0 { + return Ok(unavailable( + out, + "authored closed shadow content is unsupported", + )); + } + let mut settled = None; + let mut settling_checks = Vec::new(); + for attempt in 0..3 { + let s = scan(page, key)?; + if let Some(reason) = state_reason(&s, r) { + out.receipt["unsupported"] = s["unsupported"].clone(); + return Ok(unavailable(out, reason)); + } + let mut list = urls(&s); + if !list.contains(&r.url) { + list.push(r.url.clone()); + } + let e = evidence(page, &list)?; + let (png, first) = screenshot(page, r)?; + let middle = scan(page, key)?; + let (second_png, second) = screenshot(page, r)?; + let after = scan(page, key)?; + let end = evidence(page, &list)?; + let (changed_in_region, changed_in_viewport) = changed_pixels(&first, &second, r); + settling_checks.push(json!({"attempt":attempt+1,"domBeforeAfterStable":s==middle && s==after, + "pixelsStable":first.data==second.data,"changedPixelsInRegion":changed_in_region,"changedPixelsInViewport":changed_in_viewport, + "networkStable":stable_network(&end,e.revision),"networkRevisionBefore":e.revision,"networkRevisionAfter":end.revision, + "networkChangedDuringCollection":e.changed_during_collection})); + out.receipt["settlingChecks"] = json!(settling_checks); + out.receipt["settlingResponses"] = json!(end.responses.iter().map(|e|json!({"url":e.url,"status":e.status,"bytes":e.body.as_ref().map(Vec::len),"unavailable":e.unavailable_reason,"fromDiskCache":e.from_disk_cache})).collect::>()); + if attempt == 2 + && (s != middle + || s != after + || first.data != second.data + || !stable_network(&end, e.revision) + || e.changed_during_collection) + { + out.images.push(CaptureImage { + name: "unsettled-before.png".into(), + png: png.clone(), + }); + out.images.push(CaptureImage { + name: "unsettled-after.png".into(), + png: second_png, + }); + } + if s == middle + && s == after + && first.data == second.data + && stable_network(&end, e.revision) + && !e.changed_during_collection + { + settled = Some((s, png, first, end, list)); + break; + } + } + let Some((baseline, png, pixels, network, list)) = settled else { + out.receipt["settlingResources"] = eval( + page, + "performance.getEntriesByType('resource').slice(-128).map(r=>({name:r.name,initiatorType:r.initiatorType,startTime:r.startTime,duration:r.duration}))", + )?; + return Ok(unavailable( + out, + "page did not settle within three capture checks", + )); + }; + let document: Vec<_> = network + .responses + .iter() + .filter(|e| e.url == r.url) + .collect(); + if document.len() == 1 && !document[0].ambiguous_url && document[0].unavailable_reason.is_none() + { + out.receipt["documentResponseSha256"] = json!(document[0].body.as_deref().map(hash)); + out.receipt["captureDocument"] = json!({"requestId":document[0].request_id,"frameId":document[0].frame_id,"loaderId":document[0].loader_id}); + } + out.receipt["screenshotSha256"] = json!(hash(&png)); + out.receipt["screenshot"] = json!("baseline.png"); + out.receipt["domSha256"] = json!(hash(baseline["dom"].as_str().unwrap_or("").as_bytes())); + out.receipt["resolvedUrl"] = baseline["url"].clone(); + out.receipt["networkRevision"] = json!(network.revision); + out.images.push(CaptureImage { + name: "baseline.png".into(), + png, + }); + let unsupported: Vec = baseline["unsupported"] + .as_array() + .into_iter() + .flatten() + .filter(|item| overlaps(&item["box"], r)) + .cloned() + .collect(); + out.receipt["unsupported"] = json!(unsupported); + out.receipt["surfaceCoverage"] = json!({"status":if unsupported.is_empty(){"complete-for-supported-main-dom-surface-types"}else{"partial"}, + "unmeasuredSurfaces":unsupported,"scope":"Main-DOM IMG and supported URL background layers, including native measured pseudo-elements. Neighboring unknown surfaces cannot supply required-artwork evidence."}); + let expected = hash(&r.asset_bytes); + let matching = baseline["rows"] + .as_array() + .into_iter() + .flatten() + .filter(|row| { + overlaps(&row["box"], r) + && network.responses.iter().any(|e| { + Some(e.url.as_str()) == row["url"].as_str() + && e.body.as_deref().map(hash).as_deref() == Some(expected.as_str()) + }) + }) + .count(); + if matching > 16 { + return Ok(unavailable(out, "capture exceeds 16 matching instances")); + } + + let mut bindings = Vec::new(); + let mut instances = Vec::new(); + let mut unresolved = false; + let mut group = Vec::new(); + for row in baseline["rows"].as_array().into_iter().flatten() { + let candidates: Vec<_> = network + .responses + .iter() + .filter(|e| e.url == row["url"].as_str().unwrap_or("")) + .collect(); + let response = if candidates.len() == 1 + && !candidates[0].ambiguous_url + && candidates[0].unavailable_reason.is_none() + { + Some(candidates[0]) + } else { + None + }; + let body = response.and_then(|e| e.body.as_deref()); + let body_hash = body.map(hash); + let in_region = overlaps(&row["box"], r); + let supported = body.map(static_raster).unwrap_or(false) && row["decoded"] != false; + bindings.push(json!({"element":row,"responseSha256":body_hash,"supportedStaticRaster":supported, + "responseCandidates":candidates.iter().map(|e|json!({"requestId":e.request_id,"frameId":e.frame_id,"loaderId":e.loader_id,"status":e.status,"mimeType":e.mime_type,"ambiguousUrl":e.ambiguous_url,"unavailableReason":e.unavailable_reason,"fromDiskCache":e.from_disk_cache,"fromServiceWorker":e.from_service_worker})).collect::>()})); + if in_region && !supported { + unresolved = true; + } + if body_hash.as_deref() != Some(expected.as_str()) { + continue; + } + let b = &row["box"]; + let e = r.expected_box; + let mut instance = json!({"element":row,"responseSha256":body_hash, + "status":"outside-required-region","boxDelta":{"x":b["x"].as_f64().unwrap_or(0.)-e.x,"y":b["y"].as_f64().unwrap_or(0.)-e.y,"w":b["w"].as_f64().unwrap_or(0.)-e.w,"h":b["h"].as_f64().unwrap_or(0.)-e.h}}); + if !in_region { + instances.push(instance); + continue; + } + if !supported { + instance["status"] = json!("unavailable"); + instances.push(instance); + continue; + } + let item = json!({"index":row["index"],"kind":row["kind"],"pseudo":row["pseudo"],"layer":row["layer"]}); + group.push(item.clone()); + let (without, without_pixels) = match intervene( + page, + r, + key, + &baseline, + &pixels, + network.revision, + &list, + &json!([item]), + &mut out.images, + ) { + Ok(v) => v, + Err(failure) => { + out.receipt["retryableRasterInvalidation"] = json!(failure.raster_only); + out.receipt["interventionResources"] = eval( + page, + "performance.getEntriesByType('resource').slice(-128).map(r=>({name:r.name,initiatorType:r.initiatorType,startTime:r.startTime,duration:r.duration}))", + )?; + return Ok(unavailable(out, failure.reason)); + } + }; + let (changed, total) = changed_pixels(&pixels, &without_pixels, r); + let name = format!("without-{}.png", instances.len()); + instance["status"] = json!("measured"); + instance["changedPixelsInRegion"] = json!(changed); + instance["changedPixelsInViewport"] = json!(total); + instance["suppressedScreenshot"] = json!(name); + instance["suppressedScreenshotSha256"] = json!(hash(&without)); + instance["restorationVerified"] = json!(true); + out.images.push(CaptureImage { name, png: without }); + instances.push(instance); + } + // Individual marginal contributions can all be zero for identical stacked + // copies. Measure their union as well, excluding out-of-region instances. + if group.len() > 1 { + let (without, without_pixels) = match intervene( + page, + r, + key, + &baseline, + &pixels, + network.revision, + &list, + &json!(group), + &mut out.images, + ) { + Ok(v) => v, + Err(failure) => { + out.receipt["retryableRasterInvalidation"] = json!(failure.raster_only); + out.receipt["interventionResources"] = eval( + page, + "performance.getEntriesByType('resource').slice(-128).map(r=>({name:r.name,initiatorType:r.initiatorType,startTime:r.startTime,duration:r.duration}))", + )?; + return Ok(unavailable(out, failure.reason)); + } + }; + let (changed, total) = changed_pixels(&pixels, &without_pixels, r); + out.receipt["combinedContribution"] = json!({"status":"measured","instanceCount":group.len(),"changedPixelsInRegion":changed,"changedPixelsInViewport":total,"suppressedScreenshot":"without-combined.png","suppressedScreenshotSha256":hash(&without),"restorationVerified":true}); + out.images.push(CaptureImage { + name: "without-combined.png".into(), + png: without, + }); + } else if let Some(instance) = instances.iter().find(|i| i["status"] == "measured") { + out.receipt["combinedContribution"] = json!({"status":"measured","instanceCount":1,"changedPixelsInRegion":instance["changedPixelsInRegion"],"changedPixelsInViewport":instance["changedPixelsInViewport"],"suppressedScreenshot":instance["suppressedScreenshot"],"suppressedScreenshotSha256":instance["suppressedScreenshotSha256"],"restorationVerified":true}); + } else { + out.receipt["combinedContribution"] = + json!({"status":"no-matching-supported-instance","instanceCount":0}); + } + out.receipt["resourceBindings"] = json!(bindings); + out.receipt["instances"] = json!(instances); + let (_, final_pixels) = screenshot(page, r)?; + if scan(page, key)? != baseline + || final_pixels.data != pixels.data + || !stable_network(&evidence(page, &list)?, network.revision) + { + return Ok(unavailable(out, "page changed by final capture check")); + } + let final_coverage = page.coverage()?; + if final_coverage != coverage { + return Ok(unavailable( + out, + "native DOM coverage changed during capture", + )); + } + // Unknown neighboring renderers do not prevent an exact intervention on + // an observed PNG. Preserve that scoped evidence while keeping the broader + // capture unavailable; it is not a pass or an exhaustive surface census. + out.receipt["stableCapture"] = json!(true); + out.receipt["knownRasterEvidence"] = json!({"status":"measured","adequateVisibility":"not-assessed","inventoryCoverage":if unresolved || !unsupported.is_empty(){"partial"}else{"supported-main-dom-types"}}); + if !unsupported.is_empty() { + return Ok(unavailable( + out, + "unsupported rendered surface in required region; known raster contribution measured separately", + )); + } + if unresolved { + return Ok(unavailable( + out, + "resource identity, decode or format unavailable in required region", + )); + } + out.receipt["status"] = json!("captured"); + out.receipt["stableCapture"] = json!(true); + Ok(out) +} + +struct InterventionFailure { + reason: String, + raster_only: bool, +} +impl From for InterventionFailure { + fn from(reason: String) -> Self { + Self { + reason, + raster_only: false, + } + } +} +impl From<&str> for InterventionFailure { + fn from(reason: &str) -> Self { + reason.to_string().into() + } +} + +fn intervene( + page: &mut CapturePage<'_, '_>, + r: &AssetCaptureRequest, + key: &str, + baseline: &Value, + pixels: &Image, + revision: u64, + list: &[String], + items: &Value, + diagnostics: &mut Vec, +) -> Result<(Vec, Image), InterventionFailure> { + let before = scan(page, key)?; + let (_, before_pixels) = screenshot(page, r)?; + if &before != baseline + || before_pixels.data != pixels.data + || !stable_network(&evidence(page, list)?, revision) + { + return Err("page changed before intervention".into()); + } + let suppressed = eval(page, &format!("globalThis[{key}].suppressMany({items})")); + let intervention: Result<(Vec, Image), String> = (|| { + let suppressed = suppressed?; + page.set_stylesheet(suppressed["rules"].as_str().ok_or("capture suppression rules unavailable")?)?; + eval(page, &format!("globalThis[{key}].settle()"))?; + eval(page, &format!("globalThis[{key}].verifySuppression()"))?; + let (without, without_pixels) = screenshot(page, r)?; + let during = scan(page, key)?; + if during["dom"] != suppressed["dom"] + || during["layout"] != baseline["layout"] + || suppressed["layout"] != baseline["layout"] + || during["pseudoLayout"] != baseline["pseudoLayout"] + || during["sameNodes"] != true + || during["runningAnimations"].as_u64().unwrap_or(1) > 0 + { + return Err("page, layout or animation changed during intervention".into()); + } + Ok((without, without_pixels)) + })(); + let stylesheet_restore = page.set_stylesheet(""); + eval( + page, + &format!("(()=>{{globalThis[{key}].restore();return globalThis[{key}].settle();}})()"), + )?; + stylesheet_restore?; + let result = intervention?; + let restored_dom = scan(page, key)?; + let (restored_png, restored_pixels) = screenshot(page, r)?; + let restored_network = evidence(page, list)?; + if &restored_dom != baseline + || restored_pixels.data != pixels.data + || !stable_network(&restored_network, revision) + { + diagnostics.push(CaptureImage { + name: "restoration-failed.png".into(), + png: restored_png, + }); + return Err(InterventionFailure { + raster_only: &restored_dom == baseline && stable_network(&restored_network, revision), + reason: format!( + "intervention did not restore stable page and network (DOM {}, pixels {}, network revision {} -> {}, truncated {}, changed while reading {})", + &restored_dom == baseline, + restored_pixels.data == pixels.data, + revision, + restored_network.revision, + restored_network.truncated, + restored_network.changed_during_collection + ), + }); + } + Ok(result) +} diff --git a/crates/cli/src/capture_service.rs b/crates/cli/src/capture_service.rs new file mode 100644 index 000000000..d9ac5ac48 --- /dev/null +++ b/crates/cli/src/capture_service.rs @@ -0,0 +1,554 @@ +//! Project-scoped capture transport. Approval still belongs to the shared gate. +//! Host adapters must independently audit retained evidence before accepting a run. +use crate::entry_capture::CdpEntryRenderer; +use base64::Engine; +use impeccable_comp_verbs::entry_capture::{ + CapturedEntry, EntryRenderer, EntryRequest, EntryStage, +}; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + io::{BufRead, BufReader, Read, Write}, + net::{TcpListener, TcpStream}, + path::PathBuf, + time::{Duration, Instant}, +}; + +fn request(stream: &TcpStream, key: &str) -> Result<(String, Value), String> { + let mut reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?); + let mut first = String::new(); + reader + .by_ref() + .take(1025) + .read_line(&mut first) + .map_err(|e| e.to_string())?; + if first.len() > 1024 { + return Err("request line too long".into()); + } + let parts: Vec<_> = first.split_whitespace().collect(); + if parts.len() != 3 || parts[0] != "POST" { + return Err("POST required".into()); + } + let route = parts[1].to_string(); + let mut total = first.len(); + let mut length = None; + let mut authenticated = false; + let mut key_seen = false; + loop { + let mut line = String::new(); + reader + .by_ref() + .take(8193) + .read_line(&mut line) + .map_err(|e| e.to_string())?; + total += line.len(); + if total > 8192 || line.is_empty() { + return Err("invalid headers".into()); + } + if line == "\r\n" || line == "\n" { + break; + } + let (name, value) = line.split_once(':').ok_or("invalid header")?; + match name.to_ascii_lowercase().as_str() { + "content-length" => { + if length.is_some() { + return Err("duplicate length".into()); + } + length = Some( + value + .trim() + .parse::() + .map_err(|_| "invalid length")?, + ); + } + "x-capture-key" => { + if key_seen { + return Err("duplicate capability".into()); + } + key_seen = true; + authenticated = value.trim() == key; + } + "origin" | "transfer-encoding" => { + return Err("unsupported request origin/encoding".into()); + } + _ => {} + } + } + if !authenticated { + return Err("invalid capability".into()); + } + let length = length.ok_or("missing length")?; + if length > 16384 { + return Err("request too large".into()); + } + let mut body = vec![0; length]; + reader.read_exact(&mut body).map_err(|e| e.to_string())?; + let body: Value = serde_json::from_slice(&body).map_err(|e| e.to_string())?; + if !body.is_object() || body.get("root").is_some() || body.get("url").is_some() { + return Err("registered root only; no caller URLs".into()); + } + Ok((route, body)) +} +pub fn serve(args: &[String]) -> Result<(), Box> { + if args.len() != 2 { + return Err("expected registered-root ready-file".into()); + } + let root = std::fs::canonicalize(&args[0])?; + let key = std::env::var("IMPECCABLE_CAPTURE_CAPABILITY")?; + if key.len() < 32 { + return Err("capability too short".into()); + } + let listener = TcpListener::bind("127.0.0.1:0")?; + listener.set_nonblocking(true)?; + std::fs::write( + &args[1], + serde_json::to_vec(&json!({"port":listener.local_addr()?.port(),"root":root}))?, + )?; + let started = Instant::now(); + let mut serial = 0u64; + let mut captures: HashMap)> = HashMap::new(); + let mut latest: HashMap = HashMap::new(); + let mut active = std::collections::HashSet::new(); + let session = &impeccable_comp_verbs::asset_capture::capture_sha256(key.as_bytes())[..16]; + while started.elapsed() < Duration::from_secs(10800) { + active.retain(|id| { + captures + .get(id) + .is_some_and(|(at, _)| at.elapsed() < Duration::from_secs(180)) + }); + captures.retain(|id, _| active.contains(id) || latest.values().any(|v| v == id)); + let (mut stream, _) = match listener.accept() { + Ok(v) => v, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(10)); + continue; + } + Err(e) => return Err(e.into()), + }; + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(10)))?; + let answer = (|| -> Result { + let (route, body) = request(&stream, &key)?; + let text = |k: &str| body[k].as_str().ok_or_else(|| format!("missing {k}")); + match route.as_str() { + "/capture" => { + if active.len() >= 2 { + return Err("active capture limit".into()); + } + let stage = match text("stage")? { + "hero" => EntryStage::Hero, + "responsive" => EntryStage::Responsive, + _ => return Err("invalid stage".into()), + }; + let captured = CdpEntryRenderer.capture_entry(&EntryRequest { + root: PathBuf::from(&root), + artifact: text("entry")?.into(), + spec: text("spec")?.into(), + reference: text("reference")?.into(), + stage, + })?; + serial += 1; + let handle = format!("{session}-{serial}"); + let stage_name = text("stage")?.to_string(); + let captured = ServiceEntry::new(captured, handle.clone(), &root); + let evidence = captured.evidence(); + let frames:Vec<_>=evidence.frames.iter().map(|f|json!({"name":f.name,"png":base64::engine::general_purpose::STANDARD.encode(&f.png),"regions":f.regions.iter().map(|r|r.receipt.clone()).collect::>()})).collect(); + let response = + json!({"ok":true,"handle":handle,"report":evidence.report,"frames":frames}); + if serde_json::to_vec(&response) + .map_err(|e| e.to_string())? + .len() + > 64 * 1024 * 1024 + { + return Err("response budget exceeded".into()); + } + latest.insert(stage_name, handle.clone()); + active.insert(handle.clone()); + captures.insert(handle, (Instant::now(), Box::new(captured))); + Ok(response) + } + "/verify" => { + let handle = text("handle")?; + if !active.contains(handle) { + return Err("unknown or released capture".into()); + } + let (_, capture) = captures.get(handle).ok_or("unknown capture")?; + capture.verify_current()?; + Ok(json!({"ok":true})) + } + "/release" => Ok(json!({"ok":active.remove(text("handle")?)})), + "/audit" => { + let stage = text("stage")?; + if !matches!(stage, "hero" | "responsive") { + return Err("invalid stage".into()); + } + let id = latest.get(stage).ok_or("no host capture for stage")?; + let (_, capture) = captures.get(id).ok_or("missing host capture")?; + let saved_evidence = audit_saved(&root, stage, capture.as_ref())?; + Ok( + json!({"ok":true,"captureId":id,"inputSnapshot":capture.evidence().report["inputSnapshot"],"manifest":capture.evidence().report["manifest"],"savedEvidence":saved_evidence,"stage":stage}), + ) + } + _ => Err("unknown operation".into()), + } + })(); + let (status, body) = match answer { + Ok(v) => (200, v), + Err(e) => (400, json!({"ok":false,"error":e})), + }; + let bytes = serde_json::to_vec(&body)?; + let header = format!( + "HTTP/1.1 {status} Result\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + bytes.len() + ); + let _ = stream + .write_all(header.as_bytes()) + .and_then(|_| stream.write_all(&bytes)); + } + Ok(()) +} + +struct ServiceEntry { + source: Box, + evidence: impeccable_comp_verbs::entry_capture::EntryEvidence, +} +impl ServiceEntry { + fn new(source: Box, id: String, root: &std::path::Path) -> Self { + use impeccable_comp_verbs::{ + asset_capture::AssetCapture, + entry_capture::{EntryEvidence, FrameEvidence}, + }; + let e = source.evidence(); + let mut report = e.report.clone(); + report["captureService"] = + json!({"schema":"native-capture-service-v1","id":id,"registeredRoot":root}); + let frames = e + .frames + .iter() + .map(|f| FrameEvidence { + name: f.name.clone(), + png: f.png.clone(), + regions: f + .regions + .iter() + .map(|r| AssetCapture { + receipt: r.receipt.clone(), + images: vec![], + }) + .collect(), + }) + .collect(); + Self { + source, + evidence: EntryEvidence { report, frames }, + } + } +} +impl CapturedEntry for ServiceEntry { + fn evidence(&self) -> &impeccable_comp_verbs::entry_capture::EntryEvidence { + &self.evidence + } + fn verify_current(&self) -> Result<(), String> { + self.source.verify_current() + } +} + +fn saved(root: &std::path::Path, relative: &str) -> Result, String> { + let mut path = root.to_path_buf(); + for c in std::path::Path::new(relative).components() { + let std::path::Component::Normal(c) = c else { + return Err("invalid saved evidence path".into()); + }; + path.push(c); + if std::fs::symlink_metadata(&path) + .map_err(|e| e.to_string())? + .file_type() + .is_symlink() + { + return Err("symlink in saved evidence".into()); + } + } + let file = std::fs::File::open(path).map_err(|e| e.to_string())?; + if !file.metadata().map_err(|e| e.to_string())?.is_file() { + return Err("saved evidence is not a file".into()); + } + let mut bytes = Vec::new(); + file.take(64 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > 64 * 1024 * 1024 { + return Err("saved evidence exceeds budget".into()); + } + Ok(bytes) +} +fn audit_saved( + root: &std::path::Path, + stage: &str, + capture: &dyn CapturedEntry, +) -> Result, String> { + capture.verify_current()?; + let base = format!(".impeccable/review/native/{stage}"); + let evidence = capture.evidence(); + let mut files = Vec::new(); + let mut read = |name: &str| -> Result, String> { + let path = format!("{base}/{name}"); + let bytes = saved(root, &path)?; + files.push(json!({"path":path,"bytes":bytes.len(),"sha256":impeccable_comp_verbs::asset_capture::capture_sha256(&bytes)})); + Ok(bytes) + }; + let report: Value = serde_json::from_slice(&read("inputs.json")?).map_err(|e| e.to_string())?; + if report != evidence.report { + return Err("saved capture report differs from host evidence".into()); + } + for frame in &evidence.frames { + if read(&format!("{}.png", frame.name))? != frame.png { + return Err("saved frame differs from host capture".into()); + } + let observations: Value = + serde_json::from_slice(&read(&format!("{}-observations.json", frame.name))?) + .map_err(|e| e.to_string())?; + let expected: Vec<_> = frame.regions.iter().map(|r| r.receipt.clone()).collect(); + if observations != json!(expected) { + return Err("saved observations differ from host capture".into()); + } + } + capture.verify_current()?; + Ok(files) +} + +#[derive(Clone)] +pub struct RemoteEntryRenderer { + port: u16, + key: String, +} +impl RemoteEntryRenderer { + pub fn from_env(env: &HashMap) -> Result, String> { + match ( + env.get("IMPECCABLE_CAPTURE_PORT"), + env.get("IMPECCABLE_CAPTURE_CAPABILITY"), + ) { + (None, None) => Ok(None), + (Some(port), Some(key)) + if key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit()) => + { + let port: u16 = port.parse().map_err(|_| "invalid native capture port")?; + if port == 0 { + return Err("invalid native capture port".into()); + } + Ok(Some(Self { + port, + key: key.clone(), + })) + } + _ => Err("incomplete native capture service configuration".into()), + } + } + fn call(&self, route: &str, body: Value) -> Result { + let body = serde_json::to_vec(&body).map_err(|e| e.to_string())?; + let mut stream = TcpStream::connect_timeout( + &std::net::SocketAddr::from(([127, 0, 0, 1], self.port)), + Duration::from_secs(3), + ) + .map_err(|e| e.to_string())?; + stream + .set_read_timeout(Some(Duration::from_secs(150))) + .map_err(|e| e.to_string())?; + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .map_err(|e| e.to_string())?; + write!(stream,"POST {route} HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Capture-Key: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",self.key,body.len()).map_err(|e|e.to_string())?; + stream.write_all(&body).map_err(|e| e.to_string())?; + let mut bytes = Vec::new(); + stream + .take(64 * 1024 * 1024 + 8193) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > 64 * 1024 * 1024 + 8192 { + return Err("native capture response too large".into()); + } + let split = bytes + .windows(4) + .position(|v| v == b"\r\n\r\n") + .filter(|n| *n < 8192) + .ok_or("invalid native capture HTTP response")?; + let response: Value = + serde_json::from_slice(&bytes[split + 4..]).map_err(|e| e.to_string())?; + if response["ok"] != true { + return Err(response["error"] + .as_str() + .unwrap_or("native service rejected capture") + .into()); + } + Ok(response) + } +} +struct RemoteEntry { + renderer: RemoteEntryRenderer, + id: String, + evidence: impeccable_comp_verbs::entry_capture::EntryEvidence, +} +impl CapturedEntry for RemoteEntry { + fn evidence(&self) -> &impeccable_comp_verbs::entry_capture::EntryEvidence { + &self.evidence + } + fn verify_current(&self) -> Result<(), String> { + self.renderer + .call("/verify", json!({"handle":self.id})) + .map(|_| ()) + } +} +impl Drop for RemoteEntry { + fn drop(&mut self) { + let _ = self.renderer.call("/release", json!({"handle":self.id})); + } +} +impl EntryRenderer for RemoteEntryRenderer { + fn capture_entry(&self, r: &EntryRequest) -> Result, String> { + use impeccable_comp_verbs::{ + asset_capture::AssetCapture, + entry_capture::{EntryEvidence, FrameEvidence}, + }; + let stage = match r.stage { + EntryStage::Hero => "hero", + EntryStage::Responsive => "responsive", + }; + let result = self.call( + "/capture", + json!({"entry":r.artifact,"spec":r.spec,"reference":r.reference,"stage":stage}), + )?; + let id = result["handle"] + .as_str() + .ok_or("missing native capture handle")? + .to_string(); + let parsed = (|| -> Result, String> { + let root = std::fs::canonicalize(&r.root).map_err(|e| e.to_string())?; + if result["report"]["captureService"]["registeredRoot"] != json!(root) + || result["report"]["captureService"]["id"] != id + || result["report"]["stage"] != stage + || result["report"]["artifact"] != r.artifact + { + return Err("native capture binding mismatch".into()); + } + let expected = if stage == "hero" { + vec!["hero"] + } else { + vec!["desktop", "mobile"] + }; + let frames = result["frames"] + .as_array() + .filter(|f| f.len() == expected.len()) + .ok_or("invalid native capture frames")?; + let frames = frames + .iter() + .zip(expected) + .map(|(f, name)| -> Result { + if f["name"] != name { + return Err("invalid native capture frame name".into()); + } + let png = base64::engine::general_purpose::STANDARD + .decode(f["png"].as_str().ok_or("missing frame PNG")?) + .map_err(|e| e.to_string())?; + let regions = f["regions"] + .as_array() + .filter(|r| !r.is_empty() && r.len() <= 32) + .ok_or("invalid native capture regions")? + .iter() + .map(|r| AssetCapture { + receipt: r.clone(), + images: vec![], + }) + .collect(); + Ok(FrameEvidence { + name: name.into(), + png, + regions, + }) + }) + .collect::, _>>()?; + Ok(Box::new(RemoteEntry { + renderer: self.clone(), + id: id.clone(), + evidence: EntryEvidence { + report: result["report"].clone(), + frames, + }, + })) + })(); + if parsed.is_err() { + let _ = self.call("/release", json!({"handle":id})); + } + parsed + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn parse(bytes: String) -> Result<(String, Value), String> { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let writer = std::thread::spawn(move || { + let mut s = TcpStream::connect(addr).unwrap(); + let _ = s.write_all(bytes.as_bytes()); + }); + let (stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + let result = request(&stream, &"a".repeat(64)); + writer.join().unwrap(); + result + } + #[test] + fn request_parser_rejects_origin_ambiguity_and_unbounded_inputs() { + let header = format!( + "POST /capture HTTP/1.1\r\nX-Capture-Key: {}\r\n", + "a".repeat(64) + ); + assert!(parse(format!("{header}Content-Length: 2\r\n\r\n{{}}")).is_ok()); + for extra in [ + "Origin: http://example.com\r\n", + "Transfer-Encoding: chunked\r\n", + "Content-Length: 2\r\n", + "X-Capture-Key: wrong\r\n", + ] { + assert!(parse(format!("{header}{extra}Content-Length: 2\r\n\r\n{{}}")).is_err()); + } + assert!(parse("POST /capture HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}".into()).is_err()); + assert!(parse(format!("{header}Content-Length: 16385\r\n\r\n")).is_err()); + assert!( + parse(format!( + "{header}X-Large: {}\r\nContent-Length: 2\r\n\r\n{{}}", + "x".repeat(8192) + )) + .is_err() + ); + for body in [r#"{"root":"/"}"#, r#"{"url":"file:///outside"}"#, "[]"] { + assert!( + parse(format!( + "{header}Content-Length: {}\r\n\r\n{body}", + body.len() + )) + .is_err() + ); + } + } + #[test] + fn partial_service_configuration_never_falls_back_to_local_capture() { + assert!( + RemoteEntryRenderer::from_env(&HashMap::new()) + .unwrap() + .is_none() + ); + let mut env = HashMap::from([("IMPECCABLE_CAPTURE_PORT".into(), "12345".into())]); + assert!(RemoteEntryRenderer::from_env(&env).is_err()); + env.insert("IMPECCABLE_CAPTURE_CAPABILITY".into(), "a".repeat(64)); + assert!(RemoteEntryRenderer::from_env(&env).unwrap().is_some()); + env.insert( + "IMPECCABLE_CAPTURE_PORT".into(), + "http://example.com".into(), + ); + assert!(RemoteEntryRenderer::from_env(&env).is_err()); + } +} diff --git a/crates/cli/src/capture_snapshot.rs b/crates/cli/src/capture_snapshot.rs new file mode 100644 index 000000000..9238cfc45 --- /dev/null +++ b/crates/cli/src/capture_snapshot.rs @@ -0,0 +1,472 @@ +//! Immutable, explicit static-HTML inputs for native capture. Not a framework server. +//! The native caller owns the selection; private bound inputs are never HTTP routes. +use impeccable_comp_verbs::asset_capture::capture_sha256 as hash; +use serde_json::{Value, json}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File}, + io::{Read, Write}, + net::{TcpListener, TcpStream}, + path::{Component, Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +const MAX_FILES: usize = 1024; +const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_TOTAL_BYTES: usize = 128 * 1024 * 1024; + +pub struct SnapshotSelection { + pub root: PathBuf, + pub entry: String, + /// Explicit local static dependencies, including the entry. No directory crawling. + pub served: Vec, + /// Spec, reference and other gate inputs. Hashed but never served unless also in served. + pub bound: Vec, +} +pub struct HtmlSnapshot { + root: PathBuf, + entry: String, + served: BTreeSet, + files: BTreeMap>, + manifest: Value, + digest: String, +} +impl HtmlSnapshot { + pub fn freeze(selection: SnapshotSelection) -> Result { + let root = fs::canonicalize(&selection.root).map_err(|e| format!("snapshot root: {e}"))?; + if !root.is_dir() { + return Err("snapshot root is not a directory".into()); + } + valid_relative(&selection.entry)?; + if !matches!( + Path::new(&selection.entry) + .extension() + .and_then(|s| s.to_str()), + Some("html" | "htm") + ) { + return Err( + "snapshot requires an HTML entry; framework build binding is unsupported".into(), + ); + } + let served: BTreeSet<_> = selection.served.into_iter().collect(); + if !served.contains(&selection.entry) { + return Err("selected entry is not served".into()); + } + for name in &served { + valid_relative(name)?; + if name.split('/').any(|p| p.starts_with('.')) || mime(name).is_none() { + return Err(format!("not an allowed static dependency: {name}")); + } + } + let names: BTreeSet<_> = served.iter().cloned().chain(selection.bound).collect(); + if names.len() > MAX_FILES { + return Err("snapshot exceeds file limit".into()); + } + let mut files = BTreeMap::new(); + let mut total = 0; + for name in names { + let bytes = read_input(&root, &name)?; + total += bytes.len(); + if total > MAX_TOTAL_BYTES { + return Err("snapshot exceeds total byte limit".into()); + } + files.insert(name, bytes); + } + let manifest = json!({"schema":"native-html-input-snapshot-v1","entry":selection.entry, + "files":files.iter().map(|(name,bytes)|json!({"path":name,"sha256":hash(bytes),"bytes":bytes.len(),"served":served.contains(name)})).collect::>()}); + let digest = hash(&serde_json::to_vec(&manifest).map_err(|e| e.to_string())?); + let snapshot = Self { + root, + entry: selection.entry, + served, + files, + manifest, + digest, + }; + snapshot.verify_current()?; + Ok(snapshot) + } + /// Fresh in-process diagnostic evidence. Persisted receipts are deliberately not inputs. + pub fn capture_region( + self: &Arc, + renderer: &mut dyn impeccable_comp_verbs::asset_capture::AssetRenderer, + spec_path: &str, + region_id: &str, + reduced_motion: bool, + ) -> Result { + self.capture_regions(renderer, spec_path, &[region_id], reduced_motion)? + .into_iter() + .next() + .ok_or_else(|| "capture returned no evidence".into()) + } + /// Every requested region shares one frozen snapshot, server and browser document. + pub fn capture_regions( + self: &Arc, + renderer: &mut dyn impeccable_comp_verbs::asset_capture::AssetRenderer, + spec_path: &str, + region_ids: &[&str], + reduced_motion: bool, + ) -> Result, String> { + self.capture_regions_at_viewport(renderer, spec_path, region_ids, reduced_motion, None) + } + /// Desktop/mobile captures retain the comp-owned region coordinates scaled + /// to the requested width. Reflow fidelity remains a separate gate concern. + pub fn capture_regions_at_viewport( + self: &Arc, + renderer: &mut dyn impeccable_comp_verbs::asset_capture::AssetRenderer, + spec_path: &str, + region_ids: &[&str], + reduced_motion: bool, + viewport: Option<[u32; 2]>, + ) -> Result, String> { + use impeccable_comp_verbs::asset_capture::AssetCaptureRequest; + self.verify_current()?; + if region_ids.is_empty() + || region_ids.len() > 32 + || region_ids.iter().copied().collect::>().len() != region_ids.len() + { + return Err("capture requires 1 to 32 distinct regions".into()); + } + let spec: Value = serde_json::from_slice( + self.bytes(spec_path) + .ok_or("spec is not bound to snapshot")?, + ) + .map_err(|e| e.to_string())?; + let reference = spec["comp"].as_str().ok_or("spec has no reference")?; + let width = u32::try_from( + spec["compSize"]["width"] + .as_u64() + .ok_or("missing reference width")?, + ) + .map_err(|e| e.to_string())?; + let height = u32::try_from( + spec["compSize"]["height"] + .as_u64() + .ok_or("missing reference height")?, + ) + .map_err(|e| e.to_string())?; + let scale = viewport.map(|v| v[0] as f64 / width as f64).unwrap_or(1.); + let [width, height] = viewport.unwrap_or([width, height]); + let server = self.serve()?; + let mut requests = Vec::new(); + for region_id in region_ids { + let regions: Vec<_> = spec["regions"] + .as_array() + .ok_or("missing regions")? + .iter() + .filter(|r| r["id"] == *region_id) + .collect(); + if regions.len() != 1 { + return Err("region must resolve uniquely in bound spec".into()); + } + let region = regions[0]; + let asset = region["plate"].as_str().ok_or("region has no asset path")?; + if !self.served.contains(asset) { + return Err("required asset is not served by snapshot".into()); + } + let request = AssetCaptureRequest { + url: server.entry_url(), + viewport: [width, height], + reduced_motion, + expected_box: { + let mut b: impeccable_comp_verbs::asset_capture::CaptureBox = + serde_json::from_value(region["px"].clone()).map_err(|e| e.to_string())?; + b.x *= scale; + b.y *= scale; + b.w *= scale; + b.h *= scale; + b + }, + reference_bytes: self + .bytes(reference) + .ok_or("reference is not bound to snapshot")? + .to_vec(), + asset_bytes: self + .bytes(asset) + .ok_or("asset is not bound to snapshot")? + .to_vec(), + }; + request.validate()?; + requests.push(request); + } + let mut captures = if requests.len() == 1 { + vec![renderer.capture(&requests[0])?] + } else { + renderer.capture_batch(&requests)? + }; + self.verify_current()?; + if captures.len() != requests.len() { + return Err("native capture omitted requested regions".into()); + } + for ((capture, request), region_id) in captures.iter_mut().zip(&requests).zip(region_ids) { + let receipt = &capture.receipt; + if (receipt["status"] == "captured" || receipt["stableCapture"] == true) + && (receipt["resolvedUrl"] != request.url + || receipt["documentResponseSha256"] != hash(self.bytes(&self.entry).unwrap()) + || receipt["assetSha256"] != hash(&request.asset_bytes) + || receipt["referenceSha256"] != hash(&request.reference_bytes) + || receipt["viewport"] != json!({"width":width,"height":height,"dpr":1}) + || receipt["expectedBox"] != json!(request.expected_box) + || receipt["reducedMotion"] != reduced_motion) + { + return Err("native capture does not match frozen inputs and document".into()); + } + capture.receipt["regionId"] = json!(region_id); + capture.receipt["inputSnapshot"] = json!({"digest":self.digest,"manifest":self.manifest,"originalInputsVerified":true}); + } + Ok(captures) + } + pub fn digest(&self) -> &str { + &self.digest + } + pub fn manifest(&self) -> &Value { + &self.manifest + } + pub fn entry(&self) -> &str { + &self.entry + } + pub fn bytes(&self, name: &str) -> Option<&[u8]> { + self.files.get(name).map(Vec::as_slice) + } + pub fn verify_current(&self) -> Result<(), String> { + for (name, bytes) in &self.files { + if read_input(&self.root, name)? != *bytes { + return Err(format!("capture input changed: {name}")); + } + } + Ok(()) + } + /// Strict origin-form routes. Decode percent-encoded UTF-8, but never separators. + pub fn serve_path(&self, target: &str) -> Option { + let raw = target.strip_prefix('/')?.split('?').next()?; + let mut bytes = Vec::new(); + let input = raw.as_bytes(); + let mut i = 0; + while i < input.len() { + if input[i] == b'%' { + let hex = std::str::from_utf8(input.get(i + 1..i + 3)?).ok()?; + let byte = u8::from_str_radix(hex, 16).ok()?; + if matches!(byte, b'/' | b'\\' | 0) { + return None; + } + bytes.push(byte); + i += 3; + } else { + bytes.push(input[i]); + i += 1; + } + } + let name = String::from_utf8(bytes).ok()?; + valid_relative(&name).ok()?; + self.served.contains(&name).then_some(name) + } + pub fn serve(self: &Arc) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?; + let addr = listener.local_addr().map_err(|e| e.to_string())?; + listener.set_nonblocking(true).map_err(|e| e.to_string())?; + let host = addr.to_string(); + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = stop.clone(); + let snapshot = self.clone(); + let worker_host = host.clone(); + let worker = thread::spawn(move || { + while !worker_stop.load(Ordering::Acquire) { + match listener.accept() { + Ok((mut stream, _)) => { + let _ = respond(&mut stream, &worker_host, &snapshot); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)) + } + Err(_) => break, + } + } + }); + Ok(SnapshotServer { + host, + entry: self.entry.clone(), + stop, + worker: Some(worker), + }) + } +} +fn valid_relative(name: &str) -> Result<(), String> { + if name.is_empty() + || name.contains(['\\', '\0', '?', '#', ':']) + || name + .split('/') + .any(|p| p.is_empty() || p == "." || p == "..") + || Path::new(name) + .components() + .any(|c| !matches!(c, Component::Normal(_))) + { + return Err(format!("invalid snapshot path: {name}")); + } + Ok(()) +} +fn read_input(root: &Path, name: &str) -> Result, String> { + valid_relative(name)?; + let inspect = || -> Result { + if fs::symlink_metadata(root) + .map_err(|e| e.to_string())? + .file_type() + .is_symlink() + { + return Err("snapshot root replaced by symlink".into()); + } + let mut path = root.to_path_buf(); + for part in Path::new(name).components() { + path.push(part); + let m = + fs::symlink_metadata(&path).map_err(|e| format!("snapshot input {name}: {e}"))?; + if m.file_type().is_symlink() { + return Err(format!("symlink snapshot input: {name}")); + } + } + let m = fs::symlink_metadata(&path).map_err(|e| e.to_string())?; + if !m.is_file() || m.len() > MAX_FILE_BYTES { + return Err(format!("unsupported or oversized input: {name}")); + } + Ok(m) + }; + let before = inspect()?; + let mut file = File::open(root.join(name)).map_err(|e| e.to_string())?; + let opened = file.metadata().map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if before.dev() != opened.dev() || before.ino() != opened.ino() { + return Err(format!("input replaced while opening: {name}")); + } + } + if !opened.is_file() || opened.len() > MAX_FILE_BYTES { + return Err(format!("unsupported or oversized input: {name}")); + } + let mut bytes = Vec::new(); + (&mut file) + .take(MAX_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + let after = inspect()?; + if bytes.len() as u64 > MAX_FILE_BYTES + || before.len() != after.len() + || before.modified().ok() != after.modified().ok() + || bytes.len() as u64 != after.len() + { + return Err(format!("input changed while reading: {name}")); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if before.dev() != after.dev() || before.ino() != after.ino() { + return Err(format!("input replaced while reading: {name}")); + } + } + Ok(bytes) +} +fn mime(name: &str) -> Option<&'static str> { + Some(match Path::new(name).extension()?.to_str()? { + "html" | "htm" => "text/html; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "avif" => "image/avif", + "ico" => "image/x-icon", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "otf" => "font/otf", + _ => return None, + }) +} +fn encode_path(path: &str) -> String { + path.bytes() + .map(|b| { + if b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.' | b'~') { + (b as char).to_string() + } else { + format!("%{b:02X}") + } + }) + .collect() +} +pub struct SnapshotServer { + host: String, + entry: String, + stop: Arc, + worker: Option>, +} +impl SnapshotServer { + pub fn entry_url(&self) -> String { + format!("http://{}/{}", self.host, encode_path(&self.entry)) + } +} +impl Drop for SnapshotServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} +fn respond( + stream: &mut TcpStream, + host: &str, + snapshot: &HtmlSnapshot, +) -> Result<(), std::io::Error> { + // BSD/macOS can inherit O_NONBLOCK from the listening socket. Explicitly + // switch accepted streams back before write_all; otherwise large bodies + // stop at EWOULDBLOCK and appear as valid-header/truncated-image responses. + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + stream.set_write_timeout(Some(Duration::from_secs(2)))?; + let mut bytes = Vec::new(); + let mut buf = [0u8; 1024]; + while bytes.len() <= 8192 && !bytes.windows(4).any(|x| x == b"\r\n\r\n") { + let n = stream.read(&mut buf)?; + if n == 0 { + return Ok(()); + } + bytes.extend_from_slice(&buf[..n]); + } + let request = std::str::from_utf8(&bytes).unwrap_or(""); + let mut lines = request.split("\r\n"); + let mut first = lines.next().unwrap_or("").split_whitespace(); + let method = first.next(); + let target = first.next(); + let protocol = first.next(); + let hosts: Vec<_> = lines + .filter_map(|line| line.split_once(':')) + .filter(|(key, _)| key.eq_ignore_ascii_case("host")) + .map(|(_, value)| value.trim()) + .collect(); + let valid = bytes.len() <= 8192 + && method == Some("GET") + && protocol == Some("HTTP/1.1") + && first.next().is_none() + && hosts == [host]; + let route = if valid { + target.and_then(|t| snapshot.serve_path(t)) + } else { + None + }; + let (status, kind, body) = match route.as_deref() { + Some(name) => ("200 OK", mime(name).unwrap(), snapshot.bytes(name).unwrap()), + None => ("404 Not Found", "text/plain", b"Not found".as_slice()), + }; + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: private, max-age=3600, immutable\r\nX-Content-Type-Options: nosniff\r\n\r\n", + body.len() + )?; + stream.write_all(body) +} diff --git a/crates/cli/src/component_capture.rs b/crates/cli/src/component_capture.rs new file mode 100644 index 000000000..bfccfbbe9 --- /dev/null +++ b/crates/cli/src/component_capture.rs @@ -0,0 +1,239 @@ +//! Native component previews over the shared frozen-input/CDP capture foundation. +//! This records provenance and pixels; visual acceptance remains a separate human decision. +use base64::Engine; +use impeccable_browser::{ + cdp::{Browser, Viewport}, + discovery, + html_snapshot::HtmlSnapshot, +}; +use impeccable_context::component_review::capture::{CapturedPreviews, ComponentCapturer}; +use serde_json::{Value, json}; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; + +pub struct NativeComponentCapturer; +fn hash(bytes: &[u8]) -> String { + // The PNG module and review store use the same SHA-256; avoid a second hash contract. + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(bytes)) +} +fn source(view: &Value) -> Result<&str, String> { + view["url"] + .as_str() + .and_then(|s| s.strip_prefix("/files/")) + .ok_or_else(|| "preview needs a pinned source".into()) +} +fn material(png: &[u8], format: &str) -> Result { + if !impeccable_comp::png_io::is_png(png) || png.len() < 24 { + return Err("native review currently requires PNG raster assets".into()); + } + let width = u32::from_be_bytes(png[16..20].try_into().unwrap()); + let height = u32::from_be_bytes(png[20..24].try_into().unwrap()); + if u64::from(width) * u64::from(height) > 32_000_000 { + return Err("preview exceeds 32 megapixels".into()); + } + let image = impeccable_comp::png_io::decode_png(png)?.image; + Ok( + json!({"format":format,"width":image.width,"height":image.height,"alpha":if image.data.chunks_exact(4).any(|p|p[3]<255){"transparent"}else{"opaque"}}), + ) +} +fn render_page( + browser: &mut Browser, + snapshot: Arc, + width: u32, + height: u32, + box_: &Value, +) -> Result<(Vec, Value), String> { + let server = snapshot.serve()?; + let url = server.entry_url(); + let origin = url.split('/').take(3).collect::>().join("/"); + let mut page = browser.new_page().map_err(|e| e.message)?; + let result = (|| { + page.set_viewport(Viewport { width, height }) + .map_err(|e| e.message)?; + page.set_reduced_motion(true).map_err(|e| e.message)?; + page.begin_response_capture().map_err(|e| e.message)?; + page.goto(&url, "networkidle0", Duration::from_secs(20)) + .map_err(|e| e.message)?; + let world = page.create_isolated_world().map_err(|e| e.message)?; + let dom=page.evaluate_value_in_world(&world,r#"(async()=>{ + if(document.scripts.length||document.querySelector('iframe,frame,object,embed,canvas'))throw Error('Component capture requires static HTML/CSS/SVG; script, frame and canvas components need a supported capture adapter.'); + await Promise.race([(async()=>{await document.fonts.ready;await Promise.all([...document.images].map(i=>i.decode()));})(),new Promise((_,reject)=>setTimeout(()=>reject(Error('component resources did not settle')),5000))]); + if([...document.fonts].some(f=>f.status==='error'))throw Error('A component font failed to load.'); + if(document.getAnimations().some(a=>a.playState==='running'))throw Error('Component is animated; provide its static review state.'); + return {html:document.documentElement.outerHTML,svg:document.querySelectorAll('svg').length,images:document.images.length,controls:document.querySelectorAll('button,input,select,textarea,a[href]').length}; + })()"#).map_err(|e|e.message)?; + let coords = ["x", "y", "w", "h"].map(|k| box_[k].as_f64().unwrap()); + let clip = [ + coords[0] * width as f64, + coords[1] * height as f64, + coords[2] * width as f64, + coords[3] * height as f64, + ]; + let first = page + .screenshot_clip(clip[0], clip[1], clip[2], clip[3]) + .map_err(|e| e.message)?; + let urls = page.observed_response_urls().map_err(|e| e.message)?; + let evidence = page.response_evidence(&urls).map_err(|e| e.message)?; + if evidence.truncated + || evidence.changed_during_collection + || !evidence.missing_urls.is_empty() + { + return Err("component network evidence is incomplete".into()); + } + let mut responses = BTreeMap::new(); + for record in &evidence.responses { + if record.url.starts_with("data:image/") { + continue; + } + if record.url == format!("{origin}/favicon.ico") + && record.status == Some(404.) + && snapshot.bytes("favicon.ico").is_none() + { + continue; + } + let target = record + .url + .strip_prefix(&origin) + .ok_or("component requested an external dependency")?; + let path = snapshot + .serve_path(target) + .ok_or_else(|| format!("undeclared component dependency: {target}"))?; + let expected = snapshot.bytes(&path).ok_or("missing frozen dependency")?; + if record.status != Some(200.) + || !record.complete + || record.from_service_worker + || record.ambiguous_url + || record.body.as_deref() != Some(expected) + { + return Err(format!( + "component dependency did not match frozen bytes: {path}" + )); + } + responses.insert(path, hash(expected)); + } + if !responses.contains_key(snapshot.entry()) { + return Err("component document response is unverified".into()); + } + let second = page + .screenshot_clip(clip[0], clip[1], clip[2], clip[3]) + .map_err(|e| e.message)?; + let after = page.response_evidence(&urls).map_err(|e| e.message)?; + if first != second || after.revision != evidence.revision || after.changed_during_collection + { + return Err("component changed during capture".into()); + } + // Identity comes from the isolated document, never a producer-written receipt. + let unchanged = page + .evaluate_value_in_world(&world, "document.documentElement.outerHTML") + .map_err(|e| e.message)?; + if unchanged != dom["html"] { + return Err("component document changed during capture".into()); + } + let png = base64::engine::general_purpose::STANDARD + .decode(first) + .map_err(|e| e.to_string())?; + let proof = json!({"kind":"static-code","entry":snapshot.entry(),"inputSnapshot":snapshot.digest(),"inputs":snapshot.manifest(),"observedDependencies":responses,"domSha256":hash(dom["html"].as_str().unwrap().as_bytes()),"screenshotSha256":hash(&png),"viewport":{"width":width,"height":height,"dpr":1},"box":box_,"reducedMotion":true,"svgElements":dom["svg"],"rasterElements":dom["images"],"semanticControls":dom["controls"]}); + Ok((png, proof)) + })(); + page.close(); + result +} +impl ComponentCapturer for NativeComponentCapturer { + fn capture( + &mut self, + packet: &mut Value, + inputs: &BTreeMap>, + ) -> Result { + let width = packet["comp"]["width"] + .as_u64() + .ok_or("missing comp width")? as u32; + let height = packet["comp"]["height"] + .as_u64() + .ok_or("missing comp height")? as u32; + if u64::from(width) * u64::from(height) > 16_000_000 { + return Err("component viewport exceeds 16 megapixels".into()); + } + let reference = inputs + .get(source(&packet["comp"])?) + .ok_or("missing approved reference")?; + let reference_size = material(reference, "PNG")?; + if reference_size["width"] != width || reference_size["height"] != height { + return Err("comp dimensions do not match its PNG".into()); + } + let env = std::env::vars().collect(); + let exe = + discovery::find_browser(&env).map_err(|e| format!("browser unavailable: {e:?}"))?; + let mut browser = Browser::launch(&exe, &[], false).map_err(|e| e.message)?; + let version = browser.version().map_err(|e| e.message)?; + let result = (|| { + let mut files = BTreeMap::new(); + let mut evidence = Vec::new(); + // Reuse captures across regions sharing a source and geometry, never across changed inputs. + let mut cache: BTreeMap, Value)> = BTreeMap::new(); + for c in packet["components"] + .as_array_mut() + .ok_or("missing components")? + { + let id = c["id"].as_str().ok_or("missing component id")?.to_string(); + let mut views = serde_json::Map::new(); + for key in ["preview", "context"] { + if c.get(key).is_none() { + continue; + } + let path = source(&c[key])?.to_string(); + if key == "preview" && c[key]["kind"] == "image" { + let bytes = inputs.get(&path).ok_or("missing raster source")?; + c["material"] = material(bytes, "PNG")?; + views.insert( + key.into(), + json!({"kind":"raster-source","path":path,"sha256":hash(bytes)}), + ); + continue; + } + let mut selected = BTreeMap::new(); + for name in c["dependencies"] + .as_array() + .ok_or("missing dependencies")? + .iter() + .filter_map(Value::as_str) + .chain(std::iter::once(path.as_str())) + { + selected.insert( + name.into(), + inputs.get(name).ok_or("missing pinned dependency")?.clone(), + ); + } + let snapshot = Arc::new(HtmlSnapshot::from_pinned(path.clone(), selected)?); + let cache_key = format!("{}:{}", snapshot.digest(), c["box"]); + let (png, proof) = if let Some(saved) = cache.get(&cache_key) { + saved.clone() + } else { + let captured = + render_page(&mut browser, snapshot, width, height, &c["box"]) + .map_err(|e| format!("{id} {key}: {e}"))?; + cache.insert(cache_key, captured.clone()); + captured + }; + let output = format!("_review_captures/{}.png", hash(&png)); + c[key]["url"] = json!(format!("/files/{output}")); + c[key]["kind"] = json!("image"); + c[key]["sourceKind"] = json!("page"); + if key == "preview" { + c["material"] = material(&png, "Captured HTML / CSS / SVG")?; + } + views.insert(key.into(), proof); + files.insert(output, png); + } + // Thumbnails must show exactly the reviewable output, not a separate producer image. + c["thumbnail"] = json!({"url":c["preview"]["url"]}); + evidence.push(json!({"id":id,"views":views})); + } + Ok(CapturedPreviews { + files, + evidence: json!({"schema":"native-component-previews-v1","browser":version,"components":evidence,"scope":"Pinned raster sources and static HTML/CSS/SVG captures. No visual, semantic or human-identity approval."}), + }) + })(); + browser.close(); + result + } +} diff --git a/crates/cli/src/entry_capture.rs b/crates/cli/src/entry_capture.rs new file mode 100644 index 000000000..b65ce0ddd --- /dev/null +++ b/crates/cli/src/entry_capture.rs @@ -0,0 +1,216 @@ +//! Native static-entry adapter. One immutable snapshot; batches share a browser per viewport. +use crate::{ + asset_capture::CdpAssetRenderer, + capture_snapshot::{HtmlSnapshot, SnapshotSelection}, +}; +use impeccable_comp_verbs::entry_capture::{ + CapturedEntry, EntryEvidence, EntryRenderer, EntryRequest, EntryStage, FrameEvidence, +}; +use serde_json::{Value, json}; +use std::{fs, path::Path, sync::Arc}; + +pub struct CdpEntryRenderer; +struct FrozenEntry { + snapshot: Arc, + evidence: EntryEvidence, +} +impl CapturedEntry for FrozenEntry { + fn evidence(&self) -> &EntryEvidence { + &self.evidence + } + fn verify_current(&self) -> Result<(), String> { + self.snapshot.verify_current() + } +} +impl EntryRenderer for CdpEntryRenderer { + fn capture_entry(&self, request: &EntryRequest) -> Result, String> { + // The shared gate chooses the entry/spec/reference, never a caller URL. + let served = static_inventory(&request.root)?; + let snapshot = Arc::new(HtmlSnapshot::freeze(SnapshotSelection { + root: request.root.clone(), + entry: request.artifact.clone(), + served, + bound: vec![request.spec.clone(), request.reference.clone()], + })?); + let spec: Value = + serde_json::from_slice(snapshot.bytes(&request.spec).ok_or("missing bound spec")?) + .map_err(|e| e.to_string())?; + if spec["comp"] != request.reference { + return Err("state and spec disagree on the approved reference".into()); + } + let ids: Vec<_> = spec["regions"] + .as_array() + .ok_or("missing spec regions")? + .iter() + .filter(|r| r["medium"] == "raster") + .map(|r| r["id"].as_str().ok_or("raster region missing id")) + .collect::>()?; + if ids.is_empty() { + return Err("native raster capture requires at least one raster region; text-only capture is not supported yet".into()); + } + let width = spec["compSize"]["width"] + .as_f64() + .ok_or("missing reference width")?; + let height = spec["compSize"]["height"] + .as_f64() + .ok_or("missing reference height")?; + if width <= 0. || height <= 0. { + return Err("invalid reference dimensions".into()); + } + let frames: Vec<(&str, Option<[u32; 2]>)> = match request.stage { + EntryStage::Hero => vec![("hero", None)], + EntryStage::Responsive => vec![ + ( + "desktop", + Some([1440, (height * 1440. / width).ceil() as u32]), + ), + ( + "mobile", + Some([390, 844.max((height * 390. / width).ceil() as u32)]), + ), + ], + }; + let mut evidence = EntryEvidence { + report: json!({"schema":"native-entry-capture-v1","inputSnapshot":snapshot.digest(),"manifest":snapshot.manifest(),"artifact":request.artifact,"stage":match request.stage {EntryStage::Hero=>"hero",EntryStage::Responsive=>"responsive"},"scope":"Fresh static HTML rendering and scoped raster evidence. No independent aesthetic approval."}), + frames: vec![], + }; + for (name, viewport) in frames { + // Mobile is captured as actual page evidence; the desktop comp does + // not prescribe mobile artwork positions. Do not score its placements. + let selected = if name == "mobile" { + &ids[..1] + } else { + &ids[..] + }; + let regions = snapshot.capture_regions_at_viewport( + &mut CdpAssetRenderer::from_process_env(), + &request.spec, + selected, + true, + viewport, + )?; + if regions.iter().any(|r| { + r.receipt["stableCapture"] != true || r.receipt["batchStabilityVerified"] != true + }) { + let details = regions + .iter() + .filter(|r| { + r.receipt["stableCapture"] != true + || r.receipt["batchStabilityVerified"] != true + }) + .take(4) + .map(|r| { + format!( + "{}: {}", + r.receipt["regionId"].as_str().unwrap_or("region"), + r.receipt["individualCaptureReason"] + .as_str() + .or_else(|| r.receipt["reason"].as_str()) + .unwrap_or("capture identity changed") + ) + }) + .collect::>() + .join("; "); + return Err(format!( + "{name} native capture did not retain a stable bound document: {details}" + )); + } + let png = regions[0] + .images + .iter() + .find(|i| i.name == "baseline.png") + .ok_or("native capture has no baseline image")? + .png + .clone(); + evidence.frames.push(FrameEvidence { + name: name.into(), + png, + regions, + }); + } + snapshot.verify_current()?; + Ok(Box::new(FrozenEntry { snapshot, evidence })) + } +} + +/// Enumerate only static browser files. Never serve hidden state, source-only +/// extensions or package trees. A bound/required file omitted here is an error. +pub fn static_inventory(root: &Path) -> Result, String> { + fn walk( + root: &Path, + dir: &Path, + depth: usize, + visited: &mut usize, + out: &mut Vec, + ) -> Result<(), String> { + if depth > 12 { + return Err("static input inventory exceeds directory-depth budget".into()); + } + let mut entries = fs::read_dir(dir) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + entries.sort_by_key(|e| e.file_name()); + for entry in entries { + *visited += 1; + if *visited > 8192 { + return Err("static input inventory exceeds entry budget".into()); + } + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') || name == "node_modules" { + continue; + } + let kind = entry.file_type().map_err(|e| e.to_string())?; + if kind.is_symlink() { + return Err(format!( + "symlink in static input tree: {}", + entry.path().display() + )); + } + if kind.is_dir() { + walk(root, &entry.path(), depth + 1, visited, out)?; + } else if kind.is_file() + && matches!( + entry.path().extension().and_then(|x| x.to_str()), + Some( + "html" + | "htm" + | "css" + | "js" + | "mjs" + | "png" + | "jpg" + | "jpeg" + | "webp" + | "gif" + | "svg" + | "avif" + | "ico" + | "woff" + | "woff2" + | "ttf" + | "otf" + ) + ) + { + out.push( + entry + .path() + .strip_prefix(root) + .map_err(|e| e.to_string())? + .to_str() + .ok_or("non-UTF8 static path")? + .replace('\\', "/"), + ); + if out.len() > 1024 { + return Err("static input inventory exceeds file budget".into()); + } + } + } + Ok(()) + } + let root = fs::canonicalize(root).map_err(|e| e.to_string())?; + let mut out = Vec::new(); + walk(&root, &root, 0, &mut 0, &mut out)?; + Ok(out) +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs new file mode 100644 index 000000000..9c77a2bcd --- /dev/null +++ b/crates/cli/src/lib.rs @@ -0,0 +1,8 @@ +//! Native browser adapters, kept separate from the comp-verbs contract. +pub mod asset_capture; + +pub mod capture_snapshot; + +pub mod entry_capture; + +pub mod capture_service; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index e38c6a5d8..5a3b3f11c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -12,6 +12,7 @@ use std::io::Write; use impeccable_common::Io; mod font_render; +mod component_capture; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -66,6 +67,7 @@ fn run(args: &[String], io: &mut Io) -> i32 { "concept-seed" => impeccable_context::run_concept_seed(rest, io), "generate-image" => impeccable_context::run_generate_image(rest, io), "serve-question" => impeccable_context::run_serve_question(rest, io), + "component-review" => impeccable_context::component_review::run_with_capturer(rest, io, Some(&mut component_capture::NativeComponentCapturer)), // comp-fidelity verbs (crates/comp-verbs over crates/comp) "comp-spec" => impeccable_comp_verbs::run_comp_spec(rest, io), "comp-diff" => impeccable_comp_verbs::run_comp_diff(rest, io), @@ -73,6 +75,9 @@ fn run(args: &[String], io: &mut Io) -> i32 { let mut renderer = font_render::CdpFontRenderer::from_process_env(); impeccable_comp_verbs::run_font_match(rest, io, &mut renderer) } + "capture-server" => { + match impeccable::capture_service::serve(rest) { Ok(()) => 0, Err(e) => { io.err(&format!("Native capture service: {e}\n")); 1 } } + } "build-phase" => { // Inject the organic-clip-path CSS scanner (a rule that lives in the // closed `core` crate) so comp-verbs stays core-free. @@ -82,7 +87,11 @@ fn run(args: &[String], io: &mut Io) -> i32 { .map(|f| (f.selector, f.snippet)) .collect() }; - impeccable_comp_verbs::run_build_phase(rest, io, &organic) + match impeccable::capture_service::RemoteEntryRenderer::from_env(&io.env) { + Ok(Some(renderer)) => impeccable_comp_verbs::build_phase::run_with_renderer(rest, io, &organic, Some(&renderer)), + Ok(None) => impeccable_comp_verbs::build_phase::run_with_renderer(rest, io, &organic, Some(&impeccable::entry_capture::CdpEntryRenderer)), + Err(e) => { io.err(&format!("Native capture service: {e}\n")); 1 } + } } "hook" => impeccable_hook::run_hook(rest, io, engines().html), "hook-before-edit" => impeccable_hook::run_hook_before_edit(rest, io, engines().html), diff --git a/crates/cli/tests/asset_capture.rs b/crates/cli/tests/asset_capture.rs new file mode 100644 index 000000000..4aff4164f --- /dev/null +++ b/crates/cli/tests/asset_capture.rs @@ -0,0 +1,289 @@ +use impeccable::asset_capture::CdpAssetRenderer; +use impeccable_comp::{png_io, raster}; +use impeccable_comp_verbs::asset_capture::{AssetCaptureRequest, AssetRenderer, CaptureBox}; +use std::{ + io::{Read, Write}, + net::TcpListener, + time::Duration, +}; + +#[test] +fn native_capture_distinguishes_paint_from_file_presence() { + let env = std::env::vars().collect(); + if impeccable_browser::discovery::find_browser(&env).is_err() { + eprintln!("skip: no browser installed"); + return; + } + let image = png_io::encode_png(&raster::create_image(32, 32, [231, 60, 30, 255]), &[]).unwrap(); + let wrong = png_io::encode_png(&raster::create_image(32, 32, [20, 50, 200, 255]), &[]).unwrap(); + let transparent = png_io::encode_png(&raster::create_image(32, 32, [0, 0, 0, 0]), &[]).unwrap(); + let transparent_response = transparent.clone(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let origin = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + let asset = image.clone(); + std::thread::spawn(move || { + for mut stream in listener.incoming().flatten() { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut bytes = [0u8; 4096]; + let n = stream.read(&mut bytes).unwrap_or(0); + let line = String::from_utf8_lossy(&bytes[..n]); + let path = line.split_whitespace().nth(1).unwrap_or("/"); + let (mime, body) = if path == "/transparent.png" { + ("image/png", transparent_response.clone()) + } else if path == "/art.png" || path == "/lazy.png" { + ("image/png", asset.clone()) + } else if path == "/collision/art.png" { + ("image/png", wrong.clone()) + } else { + let mode = path.trim_start_matches('/'); + let parent = match mode { + "hidden" => "visibility:hidden", + "opacity" | "pseudo-hidden" => "opacity:0", + "clip" => "clip-path:inset(100%)", + _ => "", + }; + let position = match mode { + "shift" => "left:70px", + "footer" => "top:500px", + _ => "", + }; + let img = if mode == "pseudo-url-only" { + "
" + } else if mode.starts_with("pseudo-") && mode != "pseudo-neighbor" { + "
" + } else if mode == "layered-duplicate" { + "
" + } else if mode == "decoration" { + "" + } else if mode == "background" { + "
" + } else if mode == "wrong" || mode == "footer-trap" { + "" + } else { + "" + }; + let cover = if mode == "duplicate" { + "" + } else if mode == "covered" || mode == "pseudo-covered" { + "
" + } else { + "" + }; + let extra = match mode { + "many-text-pseudos" => "
", + "pseudo-specificity" => "", + "pseudo-mutating" => "", + "pseudo-neighbor" => { + "
" + } + "canvas-cover" => { + "" + } + "canvas-only" => { + "" + } + "tiny-token" => "", + "many" => { + "" + } + "closed-shadow" => { + "
" + } + "poison" => { + "" + } + "state-probe" => { + "" + } + "lazy" => { + "" + } + "canvas" => { + "" + } + "footer-trap" => { + "" + } + "mutating" => { + "" + } + "ambiguous" => { + "" + } + "rotated-overlap" => "", + "transition" => "", + "animated" => { + "" + } + _ => "", + }; + let html = format!( + "
{img}{cover}
{extra}" + ); + ("text/html", html.into_bytes()) + }; + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(&body); + } + }); + let mut renderer = CdpAssetRenderer::from_process_env(); + for mode in [ + "many", + "many-text-pseudos", + "closed-shadow", + "poison", + "state-probe", + "duplicate", + "decoration", + "lazy", + "baseline", + "transition", + "rotated-overlap", + "hidden", + "opacity", + "clip", + "covered", + "shift", + "footer", + "wrong", + "background", + "footer-trap", + "canvas", + "pseudo-neighbor", + "pseudo-background", + "pseudo-url-only", + "pseudo-hidden", + "pseudo-covered", + "pseudo-specificity", + "pseudo-mutating", + "layered-duplicate", + "canvas-cover", + "canvas-only", + "tiny-token", + "mutating", + "ambiguous", + "animated", + ] { + let captured = renderer + .capture(&AssetCaptureRequest { + url: format!("{origin}/{mode}"), + viewport: [240, 180], + reduced_motion: true, + reference_bytes: image.clone(), + asset_bytes: if mode == "decoration" { + transparent.clone() + } else { + image.clone() + }, + expected_box: CaptureBox { + x: 20., + y: 20., + w: 80., + h: 80., + }, + }) + .unwrap(); + if let Ok(root) = std::env::var("IMPECCABLE_CAPTURE_TEST_OUTPUT") { + let out = std::path::Path::new(&root).join(mode); + std::fs::create_dir_all(&out).unwrap(); + std::fs::write( + out.join("receipt.json"), + serde_json::to_vec_pretty(&captured.receipt).unwrap(), + ) + .unwrap(); + for image in &captured.images { + std::fs::write(out.join(&image.name), &image.png).unwrap(); + } + } + let r = &captured.receipt; + assert!(r["browser"]["product"].as_str().unwrap().contains("Chrome")); + if ["canvas", "pseudo-neighbor", "canvas-cover", "canvas-only"].contains(&mode) { + assert_eq!(r["status"], "unavailable", "{mode}: {r}"); + assert_eq!(r["stableCapture"], true, "{mode}: {r}"); + assert_eq!(r["surfaceCoverage"]["status"], "partial", "{mode}: {r}"); + assert_eq!(r["adequateVisibility"], "not-assessed", "{mode}: {r}"); + let paint = &r["combinedContribution"]["changedPixelsInRegion"]; + match mode { + "canvas" => assert_eq!(paint, 6400), + "canvas-cover" => assert_eq!(paint, 0), + "pseudo-neighbor" => { + assert!(paint.as_u64().unwrap() > 0 && paint.as_u64().unwrap() < 6400) + } + "canvas-only" => assert_eq!( + r["combinedContribution"]["status"], + "no-matching-supported-instance" + ), + _ => unreachable!(), + } + continue; + } + if mode == "tiny-token" { + assert_eq!(r["combinedContribution"]["changedPixelsInRegion"], 1); + assert_eq!(r["adequateVisibility"], "not-assessed"); + assert_eq!(r["instances"][0]["boxDelta"]["w"], -79.); + continue; + } + if [ + "many", + "closed-shadow", + "canvas", + "mutating", + "pseudo-specificity", + "pseudo-mutating", + "ambiguous", + "animated", + ] + .contains(&mode) + { + assert_eq!(r["status"], "unavailable", "{mode}: {r}"); + continue; + } + assert_eq!(r["status"], "captured", "{mode}: {r}"); + let instances = r["instances"].as_array().unwrap(); + if mode == "rotated-overlap" { + assert_eq!(instances.len(), 2); + assert!(instances.iter().all(|i| i["restorationVerified"] == true)); + assert!(r["combinedContribution"]["changedPixelsInRegion"].as_u64().unwrap() > 0); + continue; + } + if mode == "wrong" { + assert!(instances.is_empty()); + continue; + } + if mode == "duplicate" || mode == "layered-duplicate" { + assert_eq!(instances.len(), 2); + assert!(instances.iter().all(|i| i["changedPixelsInRegion"] == 0)); + assert_eq!(r["combinedContribution"]["changedPixelsInRegion"], 6400); + continue; + } + assert_eq!(instances.len(), 1, "{mode}: {r}"); + let i = &instances[0]; + if mode == "footer" || mode == "footer-trap" { + assert_eq!(i["status"], "outside-required-region"); + continue; + } + assert_eq!(i["status"], "measured", "{mode}: {r}"); + let pixels = i["changedPixelsInRegion"].as_u64().unwrap(); + if ["decoration", "hidden", "opacity", "clip", "covered", "pseudo-hidden", "pseudo-covered"].contains(&mode) { + assert_eq!(pixels, 0, "{mode}"); + } else { + assert!(pixels > 0, "{mode}"); + } + if mode == "shift" { + assert_eq!(i["boxDelta"]["x"], 50.); + assert!(pixels < 6400); + } + if mode == "poison" { + assert_eq!(i["boxDelta"]["x"], 0.); + } + if mode == "baseline" { + assert_eq!(pixels, 6400); + } + } +} diff --git a/crates/cli/tests/capture_snapshot.rs b/crates/cli/tests/capture_snapshot.rs new file mode 100644 index 000000000..682945cf7 --- /dev/null +++ b/crates/cli/tests/capture_snapshot.rs @@ -0,0 +1,454 @@ +use impeccable::capture_snapshot::{HtmlSnapshot, SnapshotSelection}; +use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "capture-snapshot-{}-{}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(root.join("assets")).unwrap(); + fs::create_dir_all(root.join(".impeccable")).unwrap(); + for (name, bytes) in [ + ("index.html", ""), + ("other.html", "other page"), + ("assets/art.png", "asset bytes"), + (".impeccable/spec.json", "{}"), + (".impeccable/comp.png", "comp bytes"), + (".env", "private"), + ] { + fs::write(root.join(name), bytes).unwrap(); + } + Self(root) + } + fn selection(&self) -> SnapshotSelection { + SnapshotSelection { + root: self.0.clone(), + entry: "index.html".into(), + served: vec!["index.html".into(), "assets/art.png".into()], + bound: vec![ + ".impeccable/spec.json".into(), + ".impeccable/comp.png".into(), + ], + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn snapshot_freezes_bytes_and_detects_every_changed_input() { + let f = Fixture::new(); + let original = HtmlSnapshot::freeze(f.selection()).unwrap(); + let mut reordered = f.selection(); + reordered.served.reverse(); + reordered.bound.reverse(); + assert_eq!( + original.digest(), + HtmlSnapshot::freeze(reordered).unwrap().digest() + ); + for name in [ + "index.html", + "assets/art.png", + ".impeccable/spec.json", + ".impeccable/comp.png", + ] { + let before = fs::read(f.0.join(name)).unwrap(); + fs::write(f.0.join(name), b"changed").unwrap(); + assert!(original.verify_current().is_err(), "{name}"); + assert_eq!(original.bytes(name).unwrap(), before); + fs::write(f.0.join(name), before).unwrap(); + original.verify_current().unwrap(); + } +} + +#[test] +fn routes_are_explicit_and_never_expose_bound_private_files() { + let f = Fixture::new(); + let s = HtmlSnapshot::freeze(f.selection()).unwrap(); + assert_eq!( + s.serve_path("/index.html?cache=2"), + Some("index.html".into()) + ); + assert_eq!( + s.serve_path("/assets/art.png"), + Some("assets/art.png".into()) + ); + for route in [ + "/", + "/other.html", + "/.env", + "/.impeccable/comp.png", + "/../index.html", + "/assets/../index.html", + "/%2e%2e/index.html", + "/assets%2fart.png", + "/assets\\art.png", + "http://example.com/index.html", + ] { + assert!(s.serve_path(route).is_none(), "{route}"); + } + let mut selection = f.selection(); + selection.served.push(".env".into()); + assert!(HtmlSnapshot::freeze(selection).is_err()); +} + +#[test] +fn snapshot_rejects_traversal_symlinks_and_non_html_entries() { + let f = Fixture::new(); + for bad in [ + "../index.html", + "/index.html", + "assets/../index.html", + "assets/art.png", + ] { + let mut selection = f.selection(); + selection.entry = bad.into(); + assert!(HtmlSnapshot::freeze(selection).is_err(), "{bad}"); + } + #[cfg(unix)] + { + std::os::unix::fs::symlink(f.0.join("assets"), f.0.join("linked")).unwrap(); + let mut selection = f.selection(); + selection.served.push("linked/art.png".into()); + assert!(HtmlSnapshot::freeze(selection).is_err()); + let s = HtmlSnapshot::freeze(f.selection()).unwrap(); + fs::rename(f.0.join("assets"), f.0.join("original-assets")).unwrap(); + std::os::unix::fs::symlink(f.0.join("original-assets"), f.0.join("assets")).unwrap(); + assert!(s.verify_current().is_err()); + } +} + +#[test] +fn fresh_capture_ignores_saved_receipts_and_rejects_stale_or_wrong_documents() { + use impeccable_comp_verbs::asset_capture::{ + AssetCapture, AssetCaptureRequest, AssetRenderer, capture_sha256, + }; + use serde_json::json; + use std::sync::Arc; + let f = Fixture::new(); + fs::write(f.0.join(".impeccable/spec.json"),serde_json::to_vec(&json!({"comp":".impeccable/comp.png","compSize":{"width":200,"height":200},"regions":[{"id":"art","plate":"assets/art.png","px":{"x":20,"y":20,"w":80,"h":80}}]})).unwrap()).unwrap(); + struct Fake { + entry: Vec, + mutation: Option, + wrong: bool, + partial: bool, + calls: usize, + } + impl AssetRenderer for Fake { + fn capture(&mut self, r: &AssetCaptureRequest) -> Result { + self.calls += 1; + assert!(r.url.starts_with("http://127.0.0.1:")); + assert!(r.url.ends_with("/index.html")); + if let Some(path) = &self.mutation { + fs::write(path, b"changed").unwrap(); + } + Ok(AssetCapture { + receipt: json!({"status":if self.partial {"unavailable"} else {"captured"},"stableCapture":true,"resolvedUrl":if self.wrong {"http://other/page"} else {&r.url},"documentResponseSha256":capture_sha256(&self.entry),"assetSha256":capture_sha256(&r.asset_bytes),"referenceSha256":capture_sha256(&r.reference_bytes),"viewport":{"width":200,"height":200,"dpr":1},"expectedBox":r.expected_box,"reducedMotion":true}), + images: vec![], + }) + } + } + let s = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap()); + let mut renderer = Fake { + entry: s.bytes("index.html").unwrap().to_vec(), + mutation: None, + wrong: false, + partial: false, + calls: 0, + }; + fs::write( + f.0.join(".impeccable/receipt.json"), + br#"{"status":"captured","visible":true}"#, + ) + .unwrap(); + let capture = s + .capture_region(&mut renderer, ".impeccable/spec.json", "art", true) + .unwrap(); + assert_eq!(capture.receipt["inputSnapshot"]["digest"], s.digest()); + assert_eq!(renderer.calls, 1); + renderer.wrong = true; + assert!( + s.capture_region(&mut renderer, ".impeccable/spec.json", "art", true) + .is_err() + ); + renderer.partial = true; + assert!( + s.capture_region(&mut renderer, ".impeccable/spec.json", "art", true) + .is_err(), + "partial stable evidence must also bind the document" + ); + renderer.partial = false; + renderer.wrong = false; + renderer.entry = b"different entry".to_vec(); + assert!( + s.capture_region(&mut renderer, ".impeccable/spec.json", "art", true) + .is_err() + ); + renderer.entry = s.bytes("index.html").unwrap().to_vec(); + renderer.mutation = Some(f.0.join("assets/art.png")); + assert!( + s.capture_region(&mut renderer, ".impeccable/spec.json", "art", true) + .is_err() + ); + let calls = renderer.calls; + assert!( + s.capture_region(&mut renderer, ".impeccable/spec.json", "art", true) + .is_err() + ); + assert_eq!( + renderer.calls, calls, + "stale input must fail before renderer launch" + ); +} + +#[test] +fn snapshot_server_serves_frozen_bytes_and_checks_host() { + use std::{ + io::{Read, Write}, + net::TcpStream, + sync::Arc, + }; + let f = Fixture::new(); + let snapshot = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap()); + let server = snapshot.serve().unwrap(); + let url = server.entry_url(); + let host = url + .strip_prefix("http://") + .unwrap() + .split('/') + .next() + .unwrap(); + let get = |path: &str, request_host: &str| { + let mut stream = TcpStream::connect(host).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: {request_host}\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + response + }; + fs::write(f.0.join("assets/art.png"), b"new bytes").unwrap(); + let actual = get("/assets/art.png", host); + assert!(actual.ends_with(b"asset bytes")); + assert!( + String::from_utf8_lossy(&actual) + .contains("Cache-Control: private, max-age=3600, immutable") + ); + assert!(String::from_utf8_lossy(&actual).starts_with("HTTP/1.1 200 OK")); + for (path, h) in [ + ("/.env", host), + ("/.impeccable/spec.json", host), + ("/other.html", host), + ("/index.html", "attacker.example"), + ("/assets/../index.html", host), + ] { + assert!(String::from_utf8_lossy(&get(path, h)).starts_with("HTTP/1.1 404")); + } + assert!(snapshot.verify_current().is_err()); +} + +#[test] +fn native_browser_capture_is_bound_to_frozen_entry_spec_and_asset() { + use impeccable::asset_capture::CdpAssetRenderer; + use impeccable_comp::{png_io, raster}; + use std::sync::Arc; + let env = std::env::vars().collect(); + if impeccable_browser::discovery::find_browser(&env).is_err() { + eprintln!("skip: browser unavailable"); + return; + } + let f = Fixture::new(); + fs::write(f.0.join("index.html"),"").unwrap(); + let image = png_io::encode_png(&raster::create_image(32, 32, [231, 60, 30, 255]), &[]).unwrap(); + fs::write(f.0.join("assets/art.png"), &image).unwrap(); + fs::write(f.0.join(".impeccable/comp.png"), &image).unwrap(); + fs::write(f.0.join(".impeccable/spec.json"),br#"{"comp":".impeccable/comp.png","compSize":{"width":200,"height":200},"regions":[{"id":"art","plate":"assets/art.png","px":{"x":20,"y":20,"w":80,"h":80}}]}"#).unwrap(); + let snapshot = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap()); + let capture = snapshot + .capture_region( + &mut CdpAssetRenderer::from_process_env(), + ".impeccable/spec.json", + "art", + true, + ) + .unwrap(); + assert_eq!(capture.receipt["status"], "captured", "{}", capture.receipt); + assert_eq!( + capture.receipt["combinedContribution"]["changedPixelsInRegion"], + 6400 + ); + assert_eq!( + capture.receipt["inputSnapshot"]["digest"], + snapshot.digest() + ); +} + +#[test] +fn batch_uses_one_document_and_rejects_mixed_inputs() { + use impeccable::asset_capture::CdpAssetRenderer; + use impeccable_comp::{png_io, raster}; + use impeccable_comp_verbs::asset_capture::{AssetCaptureRequest, AssetRenderer, CaptureBox}; + use std::sync::Arc; + let env = std::env::vars().collect(); + if impeccable_browser::discovery::find_browser(&env).is_err() { + eprintln!("skip: browser unavailable"); + return; + } + let f = Fixture::new(); + // A page-generated token must be identical for every measured region in the batch. + fs::write(f.0.join("index.html"),"").unwrap(); + let image = png_io::encode_png(&raster::create_image(32, 32, [231, 60, 30, 255]), &[]).unwrap(); + fs::write(f.0.join("assets/art.png"), &image).unwrap(); + fs::write(f.0.join(".impeccable/comp.png"), &image).unwrap(); + fs::write(f.0.join(".impeccable/spec.json"),br#"{"comp":".impeccable/comp.png","compSize":{"width":200,"height":200},"regions":[{"id":"first","plate":"assets/art.png","px":{"x":20,"y":20,"w":80,"h":80}},{"id":"second","plate":"assets/art.png","px":{"x":110,"y":110,"w":80,"h":80}}]}"#).unwrap(); + let snapshot = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap()); + let server = snapshot.serve().unwrap(); + let make = |x| AssetCaptureRequest { + url: server.entry_url(), + viewport: [200, 200], + reduced_motion: true, + reference_bytes: image.clone(), + asset_bytes: image.clone(), + expected_box: CaptureBox { + x, + y: x, + w: 80., + h: 80., + }, + }; + let mut renderer = CdpAssetRenderer::from_process_env(); + let captures = snapshot + .capture_regions( + &mut renderer, + ".impeccable/spec.json", + &["first", "second"], + true, + ) + .unwrap(); + assert_eq!(captures.len(), 2); + for capture in &captures { + assert_eq!(capture.receipt["status"], "captured", "{}", capture.receipt); + assert_eq!( + capture.receipt["combinedContribution"]["changedPixelsInRegion"], + 6400 + ); + assert_eq!( + capture.receipt["domSha256"], + captures[0].receipt["domSha256"] + ); + assert_eq!( + capture.receipt["screenshotSha256"], + captures[0].receipt["screenshotSha256"] + ); + assert_eq!( + capture.receipt["captureDocument"], + captures[0].receipt["captureDocument"] + ); + } + let mut wrong = make(110.); + wrong.url.push_str("?other-entry"); + assert!(renderer.capture_batch(&[make(20.), wrong]).is_err()); +} + +#[test] +fn snapshot_server_delivers_large_binary_response_completely() { + use std::{ + io::{Read, Write}, + net::TcpStream, + sync::Arc, + }; + let f = Fixture::new(); + let payload: Vec = (0..3 * 1024 * 1024).map(|n| (n % 251) as u8).collect(); + fs::write(f.0.join("assets/art.png"), &payload).unwrap(); + let snapshot = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap()); + let server = snapshot.serve().unwrap(); + let url = server.entry_url(); + let host = url + .strip_prefix("http://") + .unwrap() + .split('/') + .next() + .unwrap(); + let mut stream = TcpStream::connect(host).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + write!( + stream, + "GET /assets/art.png HTTP/1.1\r\nHost: {host}\r\n\r\n" + ) + .unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + let body = response.windows(4).position(|b| b == b"\r\n\r\n").unwrap() + 4; + assert_eq!( + response.len() - body, + payload.len(), + "large response was truncated" + ); + assert_eq!(&response[body..], payload); +} + +#[test] +fn viewport_capture_does_not_switch_responsive_picture_sources() { + use impeccable::asset_capture::CdpAssetRenderer; + use impeccable_comp::{png_io, raster}; + use std::sync::Arc; + if impeccable_browser::discovery::find_browser(&std::env::vars().collect()).is_err() { + return; + } + let f = Fixture::new(); + fs::write(f.0.join("index.html"), "").unwrap(); + let image = png_io::encode_png(&raster::create_image(32, 32, [231, 60, 30, 255]), &[]).unwrap(); + let mobile = + png_io::encode_png(&raster::create_image(32, 32, [30, 60, 231, 255]), &[]).unwrap(); + fs::write(f.0.join("assets/art.png"), &image).unwrap(); + fs::write(f.0.join("assets/mobile.png"), mobile).unwrap(); + fs::write(f.0.join(".impeccable/comp.png"), &image).unwrap(); + fs::write(f.0.join(".impeccable/spec.json"), br#"{"comp":".impeccable/comp.png","compSize":{"width":1440,"height":960},"regions":[{"id":"art","plate":"assets/art.png","px":{"x":20,"y":20,"w":80,"h":80}}]}"#).unwrap(); + let mut selection = f.selection(); + selection.served.push("assets/mobile.png".into()); + let snapshot = Arc::new(HtmlSnapshot::freeze(selection).unwrap()); + let captures = snapshot + .capture_regions( + &mut CdpAssetRenderer::from_process_env(), + ".impeccable/spec.json", + &["art"], + true, + ) + .unwrap(); + for capture in &captures { + assert_eq!(capture.receipt["status"], "captured", "{}", capture.receipt); + assert_eq!( + capture.receipt["combinedContribution"]["changedPixelsInRegion"], + 6400 + ); + assert_eq!(capture.receipt["batchStabilityVerified"], true); + let responses = capture.receipt["settlingResponses"].as_array().unwrap(); + assert!( + !responses + .iter() + .any(|r| r["url"].as_str().unwrap_or("").ends_with("mobile.png")) + ); + } +} diff --git a/crates/cli/tests/component_capture.rs b/crates/cli/tests/component_capture.rs new file mode 100644 index 000000000..c6735432f --- /dev/null +++ b/crates/cli/tests/component_capture.rs @@ -0,0 +1,134 @@ +//! Offline native integration: actual browser pixels and declared dependency failures. +use serde_json::{Value, json}; +use std::{ + fs, + path::PathBuf, + process::{Command, Output}, + sync::atomic::{AtomicUsize, Ordering}, +}; +static NEXT: AtomicUsize = AtomicUsize::new(0); +struct Fixture { + root: PathBuf, + project: PathBuf, + store: PathBuf, +} +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "component-native-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let project = root.join("project"); + let store = root.join("store"); + fs::create_dir_all(&project).unwrap(); + let png = impeccable_comp::png_io::encode_png( + &impeccable_comp::raster::create_image(100, 100, [25, 90, 65, 180]), + &[], + ) + .unwrap(); + fs::write(project.join("art.png"), &png).unwrap(); + fs::write(project.join("comp.png"), &png).unwrap(); + fs::write(project.join("component.html"),"").unwrap(); + fs::write( + project.join("component.css"), + "body{margin:0;background:#faf5ec}button{color:green}", + ) + .unwrap(); + Self { + root, + project, + store, + } + } + fn manifest(&self) -> Value { + json!({"schemaVersion":1,"id":"components","title":"Capture fixture","comp":{"path":"comp.png","width":100,"height":100},"components":[{"id":"art","name":"Cutout","medium":"Raster","note":"PNG","box":{"x":0,"y":0,"w":1,"h":1},"preview":{"kind":"image","path":"art.png"},"dependencies":[]},{"id":"code","name":"Code","medium":"HTML/CSS/SVG","note":"Actual code","box":{"x":0,"y":0,"w":1,"h":1},"preview":{"kind":"page","path":"component.html"},"dependencies":["component.css","art.png"]}]}) + } + fn capture(&self, input: &Value) -> Output { + fs::write( + self.project.join("review.json"), + serde_json::to_vec(input).unwrap(), + ) + .unwrap(); + Command::new(env!("CARGO_BIN_EXE_impeccable")) + .current_dir(&self.project) + .args([ + "component-review", + "capture", + "--manifest", + "review.json", + "--store", + ]) + .arg(&self.store) + .output() + .unwrap() + } + fn state(&self, output: &Output) -> Value { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let reply: Value = serde_json::from_slice(&output.stdout).unwrap(); + serde_json::from_slice( + &fs::read( + self.store + .join(reply["session"].as_str().unwrap()) + .join("current.json"), + ) + .unwrap(), + ) + .unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} +#[test] +fn captures_code_and_png_with_real_pixels_and_rejects_missing_inputs_without_replacing_review() { + let f = Fixture::new(); + let input = f.manifest(); + let state = f.state(&f.capture(&input)); + assert_eq!(state["capture"]["schema"], "native-component-previews-v1"); + let proof = &state["capture"]["components"][1]["views"]["preview"]; + assert_eq!(proof["semanticControls"], 1); + assert_eq!(proof["svgElements"], 1); + assert_eq!(proof["rasterElements"], 1); + assert!(proof["observedDependencies"]["component.css"].is_string()); + assert_eq!(state["packet"]["components"][1]["preview"]["kind"], "image"); + assert_eq!( + state["packet"]["components"][1]["preview"]["sourceKind"], + "page" + ); + let view = state["packet"]["components"][1]["preview"]["url"] + .as_str() + .unwrap(); + let path = view.splitn(4, '/').nth(3).unwrap(); + assert!(path.starts_with("_review_captures/")); + assert!(state["sources"].get(path).is_none()); + let repeated = f.state(&f.capture(&input)); + assert_eq!(state["packet"]["revision"], repeated["packet"]["revision"]); + let mut missing = input.clone(); + missing["components"][1]["dependencies"] = json!(["art.png"]); + let output = f.capture(&missing); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("component.css"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let id = fs::read_dir(&f.store) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + let after: Value = serde_json::from_slice(&fs::read(id.join("current.json")).unwrap()).unwrap(); + assert_eq!(state, after); + fs::write(f.project.join("component.html"),"").unwrap(); + let script = f.capture(&input); + assert!(!script.status.success()); + assert!(String::from_utf8_lossy(&script.stderr).contains("static HTML/CSS/SVG")); +} diff --git a/crates/cli/tests/entry_capture.rs b/crates/cli/tests/entry_capture.rs new file mode 100644 index 000000000..e260ee4fe --- /dev/null +++ b/crates/cli/tests/entry_capture.rs @@ -0,0 +1,97 @@ +use impeccable::entry_capture::{CdpEntryRenderer, static_inventory}; +use impeccable_comp::{png_io, raster}; +use impeccable_comp_verbs::entry_capture::{EntryRenderer, EntryRequest, EntryStage}; +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; +static NEXT: AtomicUsize = AtomicUsize::new(0); +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let p = std::env::temp_dir().join(format!( + "native-entry-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(p.join("assets")).unwrap(); + fs::create_dir_all(p.join(".impeccable")).unwrap(); + fs::create_dir_all(p.join("node_modules")).unwrap(); + fs::write(p.join("index.html"),"").unwrap(); + let png = + png_io::encode_png(&raster::create_image(32, 32, [231, 60, 30, 255]), &[]).unwrap(); + fs::write(p.join("assets/art.png"), &png).unwrap(); + fs::write(p.join(".impeccable/comp.png"), png).unwrap(); + fs::write(p.join(".impeccable/spec.json"),br#"{"comp":".impeccable/comp.png","compSize":{"width":200,"height":200},"regions":[{"id":"art","medium":"raster","kind":"plate","plate":"assets/art.png","px":{"x":20,"y":20,"w":80,"h":80}}]}"#).unwrap(); + fs::write(p.join(".env"), "private").unwrap(); + fs::write(p.join("node_modules/secret.js"), "private").unwrap(); + fs::write(p.join("source.ts"), "not a browser script").unwrap(); + Self(p) + } + fn request(&self, stage: EntryStage) -> EntryRequest { + EntryRequest { + root: self.0.clone(), + artifact: "index.html".into(), + spec: ".impeccable/spec.json".into(), + reference: ".impeccable/comp.png".into(), + stage, + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +#[test] +fn native_inventory_excludes_private_and_package_trees() { + let f = Fixture::new(); + assert_eq!( + static_inventory(&f.0).unwrap(), + vec!["assets/art.png", "index.html"] + ); + #[cfg(unix)] + { + std::os::unix::fs::symlink(".env", f.0.join("asset.png")).unwrap(); + assert!(static_inventory(&f.0).is_err()); + } +} +#[test] +fn entry_renderer_returns_fresh_hero_and_responsive_pixels_with_live_input_guard() { + if impeccable_browser::discovery::find_browser(&std::env::vars().collect()).is_err() { + eprintln!("skip: browser unavailable"); + return; + } + let f = Fixture::new(); + let renderer = CdpEntryRenderer; + for stage in [EntryStage::Hero, EntryStage::Responsive] { + let captured = renderer.capture_entry(&f.request(stage)).unwrap(); + captured.verify_current().unwrap(); + for frame in &captured.evidence().frames { + let image = png_io::decode_png(&frame.png).unwrap().image; + let expected = match frame.name.as_str() { + "hero" => (200, 200), + "desktop" => (1440, 1440), + "mobile" => (390, 844), + _ => panic!("unexpected frame"), + }; + assert_eq!((image.width, image.height), expected); + assert!( + frame + .regions + .iter() + .all(|r| r.receipt["stableCapture"] == true) + ); + } + let before = fs::read(f.0.join("assets/art.png")).unwrap(); + fs::write(f.0.join("assets/art.png"), b"changed").unwrap(); + assert!(captured.verify_current().is_err()); + fs::write(f.0.join("assets/art.png"), before).unwrap(); + } +} diff --git a/crates/comp-verbs/src/asset_capture.rs b/crates/comp-verbs/src/asset_capture.rs new file mode 100644 index 000000000..d5a21c40c --- /dev/null +++ b/crates/comp-verbs/src/asset_capture.rs @@ -0,0 +1,76 @@ +//! Diagnostic capture contract. Browser implementation is injected by the CLI. +//! A captured contribution is evidence, never an approval or fidelity score. +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct CaptureBox { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +pub struct AssetCaptureRequest { + pub url: String, + pub viewport: [u32; 2], + pub reduced_motion: bool, + pub reference_bytes: Vec, + pub asset_bytes: Vec, + /// Reference-owned region in viewport CSS pixels, with DPR fixed at one. + pub expected_box: CaptureBox, +} + +pub struct CaptureImage { + pub name: String, + pub png: Vec, +} +pub struct AssetCapture { + pub receipt: Value, + pub images: Vec, +} + +pub trait AssetRenderer { + /// Errors and unavailable receipts cannot satisfy a build obligation. + fn capture(&mut self, request: &AssetCaptureRequest) -> Result; + /// One document/session for all regions. Adapters must opt in explicitly; + /// looping single captures would destroy shared-document evidence. + fn capture_batch( + &mut self, + _requests: &[AssetCaptureRequest], + ) -> Result, String> { + Err("renderer does not support shared-document batch capture".into()) + } +} + +pub fn capture_sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +impl AssetCaptureRequest { + pub fn validate(&self) -> Result<(), String> { + let b = self.expected_box; + if self.viewport.contains(&0) || self.viewport.iter().any(|v| *v > 4096) { + return Err("capture viewport must be between 1 and 4096 pixels per axis".into()); + } + if ![b.x, b.y, b.w, b.h].iter().all(|n| n.is_finite()) + || b.x < 0. + || b.y < 0. + || b.w <= 0. + || b.h <= 0. + || b.x + b.w > self.viewport[0] as f64 + || b.y + b.h > self.viewport[1] as f64 + { + return Err("reference region must be finite, positive and inside the viewport".into()); + } + if self.asset_bytes.is_empty() + || self.asset_bytes.len() > 16 * 1024 * 1024 + || self.reference_bytes.is_empty() + || self.reference_bytes.len() > 64 * 1024 * 1024 + { + return Err("reference or asset bytes missing or above capture budget".into()); + } + Ok(()) + } +} diff --git a/crates/comp-verbs/src/build_phase.rs b/crates/comp-verbs/src/build_phase.rs index b830b5bdd..01bb12a82 100644 --- a/crates/comp-verbs/src/build_phase.rs +++ b/crates/comp-verbs/src/build_phase.rs @@ -19,8 +19,9 @@ use regex::Regex; use serde_json::{json, Map, Value}; use crate::comp_diff::{align_build, best_shift, build_report, compare, write_artifacts, write_region_artifacts, CompareResult, Score}; -use crate::comp_spec::{load_spec, plate_reference, BUILD_DIR, SPEC_PATH}; +use crate::comp_spec::{load_spec, prepare_plate_reference, BUILD_DIR, SPEC_PATH}; use crate::font_match::choice_stamped; +use crate::entry_capture::{CapturedEntry, EntryRenderer, EntryRequest, EntryStage}; use crate::util::{self, arg, flag, round, to_fixed}; pub const PHASES: [&str; 8] = @@ -508,6 +509,16 @@ fn gate_plates(io: &Io) -> Gate { } }; let is_texture = rr.get("kind").and_then(Value::as_str) == Some("texture"); + // comp-spec marks reference crops. Image transforms can lower visual + // similarity while preserving this direct provenance evidence; a new + // prompt tag does not turn those reference pixels into generated art. + if !is_texture { + if let Some(origin) = img.text.get("impeccable:crop-of") { + reasons.push(format!( + "plate {file} records a comp crop ({origin}); generate a production plate from the crop as reference" + )); + } + } let px_w = rr.pointer("/px/w").and_then(Value::as_f64).unwrap_or(0.0); let min_w = 1536f64.min(px_w * 1.5); if !is_texture && (img.image.width as f64) < min_w { @@ -516,10 +527,12 @@ fn gate_plates(io: &Io) -> Gate { img.image.width, px_w as i64, round(min_w) as i64 )); } - let score_val; + let mut score_val = None; + let reference = prepare_plate_reference(&comp, &spec, rr); + let reference_audit = reference.audit(); { let comp = ∁ - let refimg = plate_reference(comp, &spec, rr); + let refimg = &reference.image; // composite transparent plates over the region's sampled ground let mut build = img.image.clone(); if img.image.data.chunks_exact(4).any(|pixel| pixel[3] < 255) { @@ -533,15 +546,17 @@ fn gate_plates(io: &Io) -> Gate { build = over; } let kind = rr.get("kind").and_then(Value::as_str); - let res = compare(&refimg, &build, None, "cover", "", kind); - let score = res.whole.clone(); - score_val = Some(score.overall); - let (_, vreasons) = plate_verdict(rr, &score); - for reason in vreasons { - reasons.push(format!("plate {file}: {reason}")); + if let Some(issue) = reference.issue(&id) { + reasons.push(format!("plate {file}: {issue}")); + } else { + let score = compare(refimg, &build, None, "cover", "", kind).whole; + score_val = Some(score.overall); + let (_, vreasons) = plate_verdict(rr, &score); + for reason in vreasons { + reasons.push(format!("plate {file}: {reason}")); + } } - let is_fake = img.text.get("impeccable:fake").map(|v| v == "1").unwrap_or(false); - if !is_texture && !is_fake { + if !is_texture { let raw = r::crop( comp, rr.pointer("/px/x").and_then(Value::as_f64).unwrap_or(0.0), @@ -562,6 +577,8 @@ fn gate_plates(io: &Io) -> Gate { "id": id, "file": file, "status": if reasons.len() == reasons_before { "ok" } else { "invalid" }, "assetHash": sha256_file(io, &file), "regionHash": sha256_bytes(util::json_pretty(rr).as_bytes()), + "referenceHash": plate_reference_hash(&spec), + "reference": reference_audit, "compHash": spec.get("comp").and_then(Value::as_str).and_then(|p| sha256_file(io, p)), "size": format!("{}x{}", img.image.width, img.image.height), "score": score_val.map(util::num).unwrap_or(Value::Null) @@ -583,6 +600,12 @@ fn sha256_file(io: &Io, file: &str) -> Option { std::fs::read(abs(io, file)).ok().map(|bytes| sha256_bytes(&bytes)) } +fn plate_reference_hash(spec: &Value) -> String { + // Neighbouring regions change exclusions even when this plate is unchanged. + // Version the preparation policy so legacy approvals get revalidated once. + sha256_bytes(util::json_pretty(&json!({"policy":"plate-reference-v2","regions":spec.get("regions")})).as_bytes()) +} + fn save_plate_receipts(state: &mut Value, gate: &Gate) { if let Some(plates) = &gate.plates { let receipts: Map = plates.iter().filter_map(|p| { @@ -600,6 +623,7 @@ fn plate_receipt_current(io: &Io, state: &Value, spec: &Value, region: &Value) - receipt.get("status").and_then(Value::as_str) == Some("ok") && receipt.get("score").and_then(Value::as_f64).map(|s| s.is_finite()).unwrap_or(false) && receipt.get("file").and_then(Value::as_str) == Some(file) + && receipt.get("referenceHash").and_then(Value::as_str) == Some(plate_reference_hash(spec).as_str()) && sha256_file(io, file).as_deref().is_some_and(|h| receipt.get("assetHash").and_then(Value::as_str) == Some(h)) && sha256_file(io, comp).as_deref().is_some_and(|h| receipt.get("compHash").and_then(Value::as_str) == Some(h)) && receipt.get("regionHash").and_then(Value::as_str) == Some(sha256_bytes(util::json_pretty(region).as_bytes()).as_str()) @@ -1082,16 +1106,314 @@ fn hero_diff(io: &Io, comp_path: &str, build_path: &str, spec: Option<&Value>, o } #[allow(clippy::too_many_arguments)] -fn gate_hero(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &str, artifact: Option<&str>, organic_scan: OrganicScan) -> Gate { +struct NativeCapture { + capture: Box, + directory: String, +} +impl NativeCapture { + fn path(&self, frame: &str) -> String { + format!("{}/{}.png", self.directory, frame) + } +} +fn prepare_native_capture( + io: &Io, + state: &mut Value, + artifact: Option<&str>, + renderer: Option<&dyn EntryRenderer>, + stage: EntryStage, +) -> Result, Gate> { + if io.env("IMPECCABLE_NATIVE_CAPTURE") != Some("1") + && state["capturePolicy"] != "native-html-v1" + { + return Ok(None); + } + let renderer = renderer.ok_or_else(|| { + Gate::fail(vec![ + "native entry renderer unavailable; saved capture receipts cannot substitute".into(), + ]) + })?; + let check = gate_spec(io, state); + if !check.ok { + return Err(check); + } + let spec = load_spec(&abs(io, SPEC_PATH)); + if let Some(failure) = revalidate_plates(io, state, spec.as_ref()) { + return Err(failure); + } + let entry = artifact + .or_else(|| state["artifact"].as_str()) + .unwrap_or("index.html") + .to_string(); + let reference = state["comp"].as_str().unwrap_or("").to_string(); + let capture = renderer + .capture_entry(&EntryRequest { + root: io.cwd.clone(), + artifact: entry, + spec: SPEC_PATH.into(), + reference, + stage, + }) + .map_err(|e| Gate::fail(vec![format!("native entry capture unavailable: {e}")]))?; + let directory = format!( + ".impeccable/review/native/{}", + match stage { + EntryStage::Hero => "hero", + EntryStage::Responsive => "responsive", + } + ); + let save = (|| -> Result<(), String> { + std::fs::create_dir_all(abs(io, &directory)).map_err(|e| e.to_string())?; + for frame in &capture.evidence().frames { + if !matches!(frame.name.as_str(), "hero" | "desktop" | "mobile") { + return Err("unknown native capture frame".into()); + } + std::fs::write( + abs(io, &format!("{directory}/{}.png", frame.name)), + &frame.png, + ) + .map_err(|e| e.to_string())?; + let receipts: Vec<_> = frame.regions.iter().map(|r| r.receipt.clone()).collect(); + atomic_report( + &abs(io, &format!("{directory}/{}-observations.json", frame.name)), + &json!(receipts), + )?; + } + atomic_report( + &abs(io, &format!("{directory}/inputs.json")), + &capture.evidence().report, + )?; + capture.verify_current() + })(); + save.map_err(|e| { + Gate::fail(vec![format!( + "cannot retain fresh native entry evidence: {e}" + )]) + })?; + state["capturePolicy"] = json!("native-html-v1"); + Ok(Some(NativeCapture { capture, directory })) +} +/// Conservative frame-placement floor for the opt-in native protocol, not a +/// fidelity score: contributing instances must cover the central half of the +/// reference frame. One CSS pixel accommodates spec coordinate quantization. +fn native_frame_supported(receipt: &Value) -> bool { + let rect = |v: &Value| -> Option<[f64; 4]> { + let a = [ + v["x"].as_f64()?, + v["y"].as_f64()?, + v["w"].as_f64()?, + v["h"].as_f64()?, + ]; + (a.iter().all(|v| v.is_finite()) && a[2] > 0. && a[3] > 0.).then_some(a) + }; + let Some(expected) = rect(&receipt["expectedBox"]) else { + return false; + }; + let core = [ + expected[0] + expected[2] * 0.25, + expected[1] + expected[3] * 0.25, + expected[2] * 0.5, + expected[3] * 0.5, + ]; + let measured: Vec<_> = receipt["instances"] + .as_array() + .into_iter() + .flatten() + .filter(|i| i["status"] == "measured") + .collect(); + let mut boxes: Vec<_> = measured + .iter() + .filter(|i| i["changedPixelsInRegion"].as_u64().unwrap_or(0) > 0) + .filter_map(|i| rect(&i["element"]["box"])) + .collect(); + // Identical stacked copies can have zero individual marginal contribution. + // Only their verified positive union with the same frame supplies placement; + // a hidden large image cannot lend its frame to a tiny visible copy. + if boxes.is_empty() + && receipt["combinedContribution"]["changedPixelsInRegion"] + .as_u64() + .unwrap_or(0) + > 0 + { + let all: Vec<_> = measured + .iter() + .filter_map(|i| rect(&i["element"]["box"])) + .collect(); + if !all.is_empty() && all.len() == measured.len() && all.iter().all(|b| *b == all[0]) { + boxes.push(all[0]); + } + } + let clips: Vec<_> = boxes + .iter() + .filter_map(|b| { + let x = (b[0] - 1.).max(core[0]); + let y = (b[1] - 1.).max(core[1]); + let right = (b[0] + b[2] + 1.).min(core[0] + core[2]); + let bottom = (b[1] + b[3] + 1.).min(core[1] + core[3]); + (right > x && bottom > y).then_some([x, y, right, bottom]) + }) + .collect(); + let mut xs = vec![core[0], core[0] + core[2]]; + for b in &clips { + xs.extend([b[0], b[2]]); + } + xs.sort_by(f64::total_cmp); + xs.dedup(); + let mut area = 0.; + for pair in xs.windows(2) { + let middle = (pair[0] + pair[1]) * 0.5; + let mut spans: Vec<_> = clips + .iter() + .filter(|b| b[0] <= middle && b[2] >= middle) + .map(|b| [b[1], b[3]]) + .collect(); + spans.sort_by(|a, b| a[0].total_cmp(&b[0])); + let mut covered = 0.; + let mut end = core[1]; + for span in spans { + covered += (span[1] - span[0].max(end)).max(0.); + end = end.max(span[1]); + } + area += (pair[1] - pair[0]) * covered; + } + area >= core[2] * core[3] * (1. - 1e-9) +} + +fn finish_native_capture(io: &Io, out_dir: &str, gate: &mut Gate, native: &NativeCapture) { + let mut additions = Vec::new(); + if let Err(e) = native.capture.verify_current() { + additions.push(format!( + "native capture inputs changed before the gate completed: {e}" + )); + } + for frame in &native.capture.evidence().frames { + // Mobile has no approved mobile comp. Capture its actual pixels, but do + // not pretend the desktop's positions constrain its reflow. + if frame.name == "mobile" { + continue; + } + for region in &frame.regions { + let id = region.receipt["regionId"].as_str().unwrap_or("unknown"); + let contribution = ®ion.receipt["combinedContribution"]; + let reason = if region.receipt["stableCapture"] != true + || region.receipt["batchStabilityVerified"] != true + { + Some(format!( + "region {id}: native raster observation is unstable or unavailable" + )) + } else if contribution["status"] != "measured" { + Some(format!( + "region {id}: no supported instance of the required asset was measured in its reference region; saved asset files and footer copies are not rendered evidence" + )) + } else if contribution["changedPixelsInRegion"].as_u64().unwrap_or(0) == 0 { + Some(format!( + "region {id}: the required asset contributes no rendered pixels in its reference region (hidden, clipped, covered or transparent)" + )) + } else if !native_frame_supported(®ion.receipt) { + Some(format!( + "region {id}: contributing image frames do not cover the middle of the reference box; restore the measured placement and scale" + )) + } else { + None + }; + if let Some(reason) = reason { + record_region_reason(&mut gate.region_reasons, id, &reason); + additions.push(reason); + } + } + } + if !additions.is_empty() { + gate.ok = false; + gate.reasons.extend(additions); + gate.summary = Some(format!( + "{}; native artwork integrity failed", + gate.summary.as_deref().unwrap_or("comparison") + )); + } + // Positive contribution is only a necessary presence check. It never + // replaces the existing plate, geometry or composed-image fidelity gates. + if let Some(report_path) = &gate.report { + let mut report: Value = match std::fs::read(abs(io, report_path)) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + { + Some(report) => report, + None => { + gate.ok = false; + gate.reasons + .push("cannot read fresh comparison report for native capture evidence".into()); + gate.report = None; + return; + } + }; + report["nativeCapture"] = json!({"directory":native.directory,"inputs":native.capture.evidence().report,"integrityScope":"rendered presence and minimum frame placement; existing visual scores unchanged","framePolicy":"central-half-with-1px-quantization-tolerance","adequateVisibility":"not-established-by-integrity-checks-alone"}); + report["gate"]["ok"] = json!(gate.ok); + report["gate"]["reasons"] = json!(gate.reasons); + report["gate"]["unscopedReasons"] = json!( + gate.reasons + .iter() + .filter(|reason| !gate.region_reasons.values().any(|v| v + .as_array() + .is_some_and(|rows| rows.iter().any(|r| r.as_str() == Some(reason.as_str()))))) + .collect::>() + ); + if let Some(regions) = report["regions"].as_array_mut() { + for region in regions { + let id = region["id"].as_str().unwrap_or(""); + if let Some(blockers) = gate.region_reasons.get(id) { + region["blockingReasons"] = blockers.clone(); + if blockers.as_array().is_some_and(|a| !a.is_empty()) { + region["blocking"] = json!(true); + } + } + } + } + if let Err(e) = atomic_report(&abs(io, &format!("{out_dir}/report.json")), &report) { + gate.ok = false; + gate.reasons + .push(format!("cannot persist native capture gate evidence: {e}")); + } + } +} + +fn gate_hero( + io: &Io, + state: &mut Value, + build_path: &str, + min: f64, + out_dir: &str, + artifact: Option<&str>, + organic_scan: OrganicScan, + renderer: Option<&dyn EntryRenderer>, +) -> Gate { let pending = Gate::fail(vec!["hero comparison has not completed".into()]); if let Err(e) = unavailable_report(io, out_dir, &pending, "hero") { return Gate::fail(vec![format!("cannot persist hero gate evidence: {e}")]); } - let mut gate = gate_hero_inner(io, state, build_path, min, out_dir, artifact, organic_scan); + let native = match prepare_native_capture(io, state, artifact, renderer, EntryStage::Hero) { + Ok(value) => value, + Err(gate) => { + let _ = unavailable_report(io, out_dir, &gate, "hero"); + return gate; + } + }; + let native_path = native.as_ref().map(|n| n.path("hero")); + let mut gate = gate_hero_inner( + io, + state, + native_path.as_deref().unwrap_or(build_path), + min, + out_dir, + artifact, + organic_scan, + ); + if let Some(native) = &native { + finish_native_capture(io, out_dir, &mut gate, native); + } if gate.report.is_none() { if let Err(e) = unavailable_report(io, out_dir, &gate, "hero") { gate.ok = false; - gate.reasons.push(format!("cannot persist hero gate evidence: {e}")); + gate.reasons + .push(format!("cannot persist hero gate evidence: {e}")); } } gate @@ -1756,12 +2078,14 @@ fn hash_file(io: &Io, file: &str) -> Option { Some(d.iter().map(|b| format!("{b:02x}")).collect::()[..12].to_string()) } -fn gate_responsive(io: &Io, state: &mut Value, min: f64, out_dir: &str) -> Gate { +fn gate_responsive(io: &Io, state: &mut Value, min: f64, out_dir: &str, renderer: Option<&dyn EntryRenderer>) -> Gate { let pending = Gate::fail(vec!["responsive comparison has not completed".into()]); if let Err(e) = unavailable_report(io, out_dir, &pending, "responsive") { return Gate::fail(vec![format!("cannot persist responsive gate evidence: {e}")]); } - let mut gate = gate_responsive_inner(io, state, min, out_dir); + let native=match prepare_native_capture(io,state,None,renderer,EntryStage::Responsive) {Ok(value)=>value,Err(gate)=>{let _=unavailable_report(io,out_dir,&gate,"responsive");return gate;}}; + let mut gate = gate_responsive_inner(io, state, min, out_dir, native.as_ref()); + if let Some(native)=&native {finish_native_capture(io,out_dir,&mut gate,native);} if gate.report.is_none() { if let Err(e) = unavailable_report(io, out_dir, &gate, "responsive") { gate.ok = false; @@ -1771,9 +2095,11 @@ fn gate_responsive(io: &Io, state: &mut Value, min: f64, out_dir: &str) -> Gate gate } -fn gate_responsive_inner(io: &Io, state: &mut Value, min: f64, out_dir: &str) -> Gate { - let desktop = ".impeccable/review/desktop.png"; - let mobile = ".impeccable/review/mobile.png"; +fn gate_responsive_inner(io: &Io, state: &mut Value, min: f64, out_dir: &str, native: Option<&NativeCapture>) -> Gate { + let native_desktop=native.map(|n|n.path("desktop")); + let native_mobile=native.map(|n|n.path("mobile")); + let desktop = native_desktop.as_deref().unwrap_or(".impeccable/review/desktop.png"); + let mobile = native_mobile.as_deref().unwrap_or(".impeccable/review/mobile.png"); let mut reasons = Vec::new(); if !abs(io, desktop).exists() { reasons.push(format!("no {desktop}: capture the page at a common desktop width (1440 wide, full page) into that path")); @@ -1813,7 +2139,7 @@ fn gate_responsive_inner(io: &Io, state: &mut Value, min: f64, out_dir: &str) -> true }) .cloned().collect(); - let contradicted_direction: Vec = regions.iter().filter(|r| r.get("verdict").and_then(Value::as_str) == Some("contradicted") && r.get("kind").and_then(Value::as_str) == Some("text")).cloned().collect(); + let contradicted_direction: Vec = regions.iter().filter(|r| r.get("verdict").and_then(Value::as_str) == Some("contradicted") && matches!(r.get("kind").and_then(Value::as_str), Some("text" | "control"))).cloned().collect(); for region in &mut regions { if region.get("verdict").and_then(Value::as_str) == Some("missing") && matches!(region.get("kind").and_then(Value::as_str), Some("plate" | "image")) @@ -1878,7 +2204,7 @@ struct GateOpts { artifact: Option, } -fn run_gate(io: &Io, state: &mut Value, phase: &str, opts: &GateOpts, organic_scan: OrganicScan) -> Gate { +fn run_gate(io: &Io, state: &mut Value, phase: &str, opts: &GateOpts, organic_scan: OrganicScan, renderer: Option<&dyn EntryRenderer>) -> Gate { match phase { "comps" => gate_comps(io), "spec" => gate_spec(io, state), @@ -1886,9 +2212,9 @@ fn run_gate(io: &Io, state: &mut Value, phase: &str, opts: &GateOpts, organic_sc "hero" => { let build_path = opts.build_path.clone().unwrap_or_else(|| HERO_REPRO.to_string()); let min = opts.min.unwrap_or(HERO_MIN); - gate_hero(io, state, &build_path, min, ".impeccable/review/diff/hero", opts.artifact.as_deref(), organic_scan) + gate_hero(io, state, &build_path, min, ".impeccable/review/diff/hero", opts.artifact.as_deref(), organic_scan, renderer) } - "responsive" => gate_responsive(io, state, opts.min.unwrap_or(RESPONSIVE_MIN), ".impeccable/review/diff/desktop"), + "responsive" => gate_responsive(io, state, opts.min.unwrap_or(RESPONSIVE_MIN), ".impeccable/review/diff/desktop", renderer), _ => Gate::ok("no mechanical gate".into()), } } @@ -1926,7 +2252,7 @@ fn phase_index(phase: &str) -> Option { PHASES.iter().position(|&p| p == phase) } -fn advance(io: &Io, state: &mut Value, force: bool, reason: Option<&str>, opts: &GateOpts, organic_scan: OrganicScan) -> AdvanceResult { +fn advance(io: &Io, state: &mut Value, force: bool, reason: Option<&str>, opts: &GateOpts, organic_scan: OrganicScan, renderer: Option<&dyn EntryRenderer>) -> AdvanceResult { let phase = state.get("phase").and_then(Value::as_str).unwrap_or("").to_string(); let idx = phase_index(&phase); if idx.is_none() || phase == "review" { @@ -1947,7 +2273,7 @@ fn advance(io: &Io, state: &mut Value, force: bool, reason: Option<&str>, opts: let a = p.get("attempts").and_then(Value::as_i64).unwrap_or(0) + 1; p.insert("attempts".into(), json!(a)); } - let mut gate = run_gate(io, state, &phase, opts, organic_scan); + let mut gate = run_gate(io, state, &phase, opts, organic_scan, renderer); if let Some(p) = state.pointer_mut(&format!("/phases/{phase}")).and_then(|p| p.as_object_mut()) { p.insert("gate".into(), gate.record_json(&now())); } @@ -2014,6 +2340,17 @@ fn advance(io: &Io, state: &mut Value, force: bool, reason: Option<&str>, opts: fn next_instruction(io: &Io, state: &Value) -> String { let s = self_cmd(io); let phase = state.get("phase").and_then(Value::as_str).unwrap_or(""); + if phase == "review" && state.get("artifact").and_then(Value::as_str).is_some() + && state.pointer("/finish/disposition").and_then(Value::as_str) == Some("ship") + { + let completion = crate::completion::report(&io.cwd, Some(state), None); + match completion.get("status").and_then(Value::as_str) { + Some("complete") => return "Finish is recorded for the current entry. Repeat final review and finish if the entry or its dependencies change.".into(), + Some("changed-after-finish") => return format!("The entry changed after finish. Repeat final review, then {s} build-phase finish --disposition to validate the current artifact."), + Some("unverified") => return format!("The recorded finish cannot be verified against the entry. Check the artifact path and repeat final review before {s} build-phase finish --disposition ."), + _ => {} + } + } let comp = state.get("comp").and_then(Value::as_str).unwrap_or(""); let direction = state.get("direction").and_then(Value::as_str); let bp = state.get("breakpoint").and_then(Value::as_str); @@ -2043,6 +2380,114 @@ fn next_instruction(io: &Io, state: &Value) -> String { mod transparency_guidance_tests { use super::*; + #[test] + fn native_frame_support_rejects_displacement_and_token_images() { + let region = |boxes: Vec| json!({"expectedBox":{"x":20.,"y":20.,"w":80.,"h":80.},"instances":boxes.into_iter().map(|b|json!({"status":"measured","changedPixelsInRegion":1,"element":{"box":b}})).collect::>()}); + assert!(native_frame_supported(®ion(vec![ + json!({"x":20.,"y":20.,"w":80.,"h":80.}) + ]))); + assert!(native_frame_supported(®ion(vec![ + json!({"x":21.,"y":21.,"w":78.,"h":78.}) + ]))); + assert!(!native_frame_supported(®ion(vec![ + json!({"x":60.,"y":20.,"w":80.,"h":80.}) + ]))); + assert!(!native_frame_supported(®ion(vec![ + json!({"x":59.,"y":59.,"w":2.,"h":2.}) + ]))); + assert!(native_frame_supported(®ion(vec![ + json!({"x":20.,"y":20.,"w":40.,"h":80.}), + json!({"x":60.,"y":20.,"w":40.,"h":80.}) + ]))); + assert!(!native_frame_supported(®ion(vec![ + json!({"x":20.,"y":20.,"w":30.,"h":80.}), + json!({"x":70.,"y":20.,"w":30.,"h":80.}) + ]))); + let mut duplicate = region(vec![ + json!({"x":20.,"y":20.,"w":80.,"h":80.}), + json!({"x":20.,"y":20.,"w":80.,"h":80.}), + ]); + for item in duplicate["instances"].as_array_mut().unwrap() { + item["changedPixelsInRegion"] = json!(0); + } + duplicate["combinedContribution"] = json!({"changedPixelsInRegion":6400}); + assert!(native_frame_supported(&duplicate)); + let mut hidden_large = region(vec![ + json!({"x":20.,"y":20.,"w":80.,"h":80.}), + json!({"x":59.,"y":59.,"w":2.,"h":2.}), + ]); + hidden_large["instances"][0]["changedPixelsInRegion"] = json!(0); + assert!(!native_frame_supported(&hidden_large)); + } + + #[test] + fn native_capture_rechecks_inputs_and_updates_the_persisted_gate() { + struct Changed(crate::entry_capture::EntryEvidence); + impl CapturedEntry for Changed { + fn evidence(&self) -> &crate::entry_capture::EntryEvidence { + &self.0 + } + fn verify_current(&self) -> Result<(), String> { + Err("entry bytes changed".into()) + } + } + let dir = std::env::temp_dir().join(format!("native-entry-stale-{}", std::process::id())); + std::fs::create_dir_all(dir.join("review")).unwrap(); + let (io, _) = Io::captured("", dir.clone(), Default::default()); + atomic_report( + &dir.join("review/report.json"), + &json!({"gate":{"ok":true},"regions":[]}), + ) + .unwrap(); + let capture = NativeCapture { + capture: Box::new(Changed(crate::entry_capture::EntryEvidence { + report: json!({"inputSnapshot":"original"}), + frames: vec![], + })), + directory: "native".into(), + }; + let mut gate = Gate::fail(vec![]); + gate.ok = true; + gate.report = Some("review/report.json".into()); + finish_native_capture(&io, "review", &mut gate, &capture); + assert!(!gate.ok); + assert!(gate.reasons.join(" ").contains("entry bytes changed")); + let report: Value = + serde_json::from_slice(&std::fs::read(dir.join("review/report.json")).unwrap()) + .unwrap(); + assert_eq!(report["gate"]["ok"], false); + assert_eq!(report["gate"]["reasons"], json!(gate.reasons)); + assert_eq!( + report["nativeCapture"]["inputs"]["inputSnapshot"], + "original" + ); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn requested_native_capture_cannot_fall_back_to_saved_receipts() { + let dir = + std::env::temp_dir().join(format!("native-entry-no-renderer-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (io, _) = Io::captured( + "", + dir.clone(), + [("IMPECCABLE_NATIVE_CAPTURE".into(), "1".into())].into(), + ); + let mut state = json!({"artifact":"index.html","comp":"comp.png"}); + let result = prepare_native_capture( + &io, + &mut state, + None, + None, + crate::entry_capture::EntryStage::Hero, + ); + assert!(result.is_err()); + let error = result.err().unwrap(); + assert!(error.reasons.join(" ").contains("renderer unavailable")); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn plate_gate_scores_sparse_and_partial_alpha_on_the_sampled_ground() { let dir = std::env::temp_dir().join(format!("impeccable-plate-alpha-{}", std::process::id())); @@ -2069,11 +2514,10 @@ mod transparency_guidance_tests { let mut flattened = r::create_image(64, 64, [24, 48, 64, 255]); r::blit(&mut flattened, &plate, 0.0, 0.0); std::fs::write(dir.join("comp.png"), png_io::encode_png(&flattened, &[]).unwrap()).unwrap(); - // Exclude the separate anti-crop gate: this checks the score's ground. - let metadata = [("impeccable:fake".into(), "1".into())]; - std::fs::write(dir.join("plate.png"), png_io::encode_png(&plate, &metadata).unwrap()).unwrap(); + // Compare scores independently of any other gate findings. + std::fs::write(dir.join("plate.png"), png_io::encode_png(&plate, &[]).unwrap()).unwrap(); let alpha_score = gate_plates(&io).plates.unwrap()[0]["score"].as_f64().unwrap(); - std::fs::write(dir.join("plate.png"), png_io::encode_png(&flattened, &metadata).unwrap()).unwrap(); + std::fs::write(dir.join("plate.png"), png_io::encode_png(&flattened, &[]).unwrap()).unwrap(); let opaque_score = gate_plates(&io).plates.unwrap()[0]["score"].as_f64().unwrap(); assert!((alpha_score - opaque_score).abs() < 1e-9, "partial={partial}: {alpha_score} != {opaque_score}"); } @@ -2189,12 +2633,23 @@ fn render_status(io: &Io, state: &Value) -> String { /// `impeccable build-phase ...` pub fn run(argv: &[String], io: &mut Io, organic_scan: OrganicScan) -> i32 { + run_with_renderer(argv,io,organic_scan,None) +} +pub fn run_with_renderer(argv: &[String],io: &mut Io,organic_scan: OrganicScan,renderer: Option<&dyn EntryRenderer>) -> i32 { let cmd = argv.first().map(String::as_str); if cmd.is_none() || flag(argv, "help") { - io.err("usage: build-phase.mjs start --comp [--breakpoint WxH] | status [--json] | advance [--force --reason \"...\"] | record hero --build | scaffold | note \"\" | finish --disposition \n"); + io.err("usage: build-phase.mjs start --comp [--breakpoint WxH] [--artifact ] [--session-id ] | status [--json] | completion [--session-id ] | advance [--force --reason \"...\"] | record hero --build | scaffold | note \"\" | finish --disposition \n"); return 1; } let cmd = cmd.unwrap(); + if cmd == "completion" { + let state = load_state(io); + let session_id = arg(argv, "session-id").or_else(|| io.env("IMPECCABLE_SESSION_ID")) + .or_else(|| io.env("CODEX_THREAD_ID")); + let report = crate::completion::report(&io.cwd, state.as_ref(), session_id); + io.out(&format!("{}\n", util::json_pretty(&report))); + return 0; + } if cmd == "start" { let comp = arg(argv, "comp"); let direction = arg(argv, "direction"); @@ -2233,7 +2688,17 @@ pub fn run(argv: &[String], io: &mut Io, organic_scan: OrganicScan) -> i32 { return 0; } } - let state = new_state(comp, breakpoint.as_deref(), arg(argv, "artifact"), direction); + let mut state = new_state(comp, breakpoint.as_deref(), arg(argv, "artifact"), direction); + if io.env("IMPECCABLE_NATIVE_CAPTURE")==Some("1") {state["capturePolicy"]=json!("native-html-v1");} + // Session identity is transport metadata, never guessed from a project + // path or an earlier build. Old/unidentified states remain unscoped. + if let Some(session_id) = arg(argv, "session-id") + .or_else(|| io.env("IMPECCABLE_SESSION_ID")) + .or_else(|| io.env("CODEX_THREAD_ID")) + .filter(|s| !s.is_empty()) + { + state["sessionId"] = json!(session_id); + } save_state(io, &state); io.out(&format!("{}\n", render_status(io, &state))); return 0; @@ -2289,7 +2754,7 @@ pub fn run(argv: &[String], io: &mut Io, organic_scan: OrganicScan) -> i32 { } let build_path = arg(argv, "build").unwrap_or(HERO_REPRO).to_string(); let min = arg(argv, "min").map(|m| util::parse_f64(m, HERO_MIN)).unwrap_or(HERO_MIN); - let gate = gate_hero(io, &mut state, &build_path, min, ".impeccable/review/diff/hero", None, organic_scan); + let gate = gate_hero(io, &mut state, &build_path, min, ".impeccable/review/diff/hero", None, organic_scan, renderer); let records = state.pointer("/phases/hero/records").and_then(Value::as_i64).unwrap_or(0) + 1; if let Some(h) = state.pointer_mut("/phases/hero").and_then(|v| v.as_object_mut()) { h.insert("records".into(), json!(records)); @@ -2336,7 +2801,7 @@ pub fn run(argv: &[String], io: &mut Io, organic_scan: OrganicScan) -> i32 { min: arg(argv, "min").map(|m| util::parse_f64(m, f64::NAN)), artifact: arg(argv, "artifact").map(String::from), }; - let res = advance(io, &mut state, flag(argv, "force"), arg(argv, "reason"), &opts, organic_scan); + let res = advance(io, &mut state, flag(argv, "force"), arg(argv, "reason"), &opts, organic_scan, renderer); save_state(io, &state); if !res.ok { io.out(&format!("GATE {} FAILED (state unchanged)\n", res.phase.to_uppercase())); @@ -2382,18 +2847,7 @@ pub fn run(argv: &[String], io: &mut Io, organic_scan: OrganicScan) -> i32 { return 1; } let disposition = disposition.unwrap(); - let open_before: Vec = PHASES - .iter() - .filter(|&&ph| { - ph != "review" - && state - .pointer(&format!("/phases/{ph}/status")) - .and_then(Value::as_str) - .map(|st| st != "closed" && st != "skipped") - .unwrap_or(false) - }) - .map(|s| s.to_string()) - .collect(); + let open_before = crate::completion::open_phases(&state, false); if disposition == "ship" && !open_before.is_empty() { let phase = state.get("phase").and_then(Value::as_str).unwrap_or(""); io.err(&format!( @@ -2403,8 +2857,37 @@ pub fn run(argv: &[String], io: &mut Io, organic_scan: OrganicScan) -> i32 { )); return 2; } + // Review often changes CSS after responsive passed. A finish signature + // must cover a fresh native comparison of the final page, not merely + // a new hash alongside historical gates or model-supplied screenshots. + if disposition == "ship" && (state["capturePolicy"] == "native-html-v1" + || io.env("IMPECCABLE_NATIVE_CAPTURE") == Some("1")) { + let gate = gate_responsive(io, &mut state, RESPONSIVE_MIN, + ".impeccable/review/diff/desktop", renderer); + let at = now(); + let responsive = &mut state["phases"]["responsive"]; + responsive["attempts"] = json!(responsive["attempts"].as_u64().unwrap_or(0) + 1); + responsive["gate"] = gate.record_json(&at); + if !gate.ok { + responsive["status"] = json!("open"); + responsive["closedAt"] = Value::Null; + state["phase"] = json!("responsive"); + state["phases"]["review"]["status"] = json!("open"); + state["phases"]["review"]["closedAt"] = Value::Null; + state["finish"] = json!({"disposition":"fix", "at":at, + "phaseAtFinish":"responsive", "reason":"final native comparison failed"}); + save_state(io, &state); + io.err("build-phase: finish --disposition ship refused: final native responsive comparison failed. Responsive reopened; repair the reported findings and advance it before finishing.\n"); + for reason in &gate.reasons { io.err(&format!(" - {reason}\n")); } + return 2; + } + responsive["closedAt"] = json!(at); + } let phase = state.get("phase").and_then(Value::as_str).unwrap_or("").to_string(); state.as_object_mut().unwrap().insert("finish".into(), json!({ "disposition": disposition, "at": now(), "phaseAtFinish": phase })); + if let Some(hash) = crate::completion::artifact_hash(&io.cwd, &state) { + state["finish"]["artifactSha256"] = json!(hash); + } if phase == "review" { if let Some(rev) = state.pointer_mut("/phases/review").and_then(|v| v.as_object_mut()) { rev.insert("status".into(), json!("closed")); diff --git a/crates/comp-verbs/src/build_phase/integrity_tests.rs b/crates/comp-verbs/src/build_phase/integrity_tests.rs index 81640825e..40c2fb6c1 100644 --- a/crates/comp-verbs/src/build_phase/integrity_tests.rs +++ b/crates/comp-verbs/src/build_phase/integrity_tests.rs @@ -1,5 +1,85 @@ use super::*; +#[test] +fn completion_is_scoped_and_detects_post_finish_edits() { + let ws = Workspace::new(); + ws.write("comp.png", b"fixture"); + ws.write("index.html", b"
First
"); + let mut io = ws.io(); + let argv = ["start", "--comp", "comp.png", "--artifact", "index.html", "--session-id", "owner"].map(String::from); + assert_eq!(run(&argv, &mut io, &no_organic_scan), 0); + let mut state = load_state(&io).unwrap(); + assert_eq!(state["sessionId"], "owner"); + let status = crate::completion::report(&ws.path, Some(&state), Some("owner")); + assert_eq!(status["canContinue"], true); + assert_eq!(crate::completion::report(&ws.path, Some(&state), Some("other"))["canContinue"], false); + for phase in PHASES { state["phases"][phase]["status"] = json!("closed"); } + state["phase"] = json!("review"); + save_state(&io, &state); + let finish = ["finish", "--disposition", "ship"].map(String::from); + assert_eq!(run(&finish, &mut io, &no_organic_scan), 0); + let state = load_state(&io).unwrap(); + assert_eq!(crate::completion::report(&ws.path, Some(&state), Some("owner"))["status"], "complete"); + ws.write("index.html", b"
Changed after finish
"); + assert_eq!(crate::completion::report(&ws.path, Some(&state), Some("owner"))["status"], "changed-after-finish"); +} + +#[test] +fn status_next_step_tracks_the_recorded_finish_and_later_entry_edits() { + let ws = Workspace::new(); + ws.write("index.html", b"
Finished
"); + let io = ws.io(); + let mut state = json!({"phase":"review", "artifact":"index.html", "phases":{}}); + for phase in PHASES { + state["phases"][phase] = json!({"status":"closed"}); + } + state["finish"] = json!({"disposition":"ship", "artifactSha256":crate::completion::artifact_hash(&ws.path, &state)}); + let finished = next_instruction(&io, &state); + assert!(finished.contains("Finish is recorded for the current entry"), "{finished}"); + assert!(!finished.contains("Spawn")); + ws.write("index.html", b"
Changed after finish
"); + let changed = next_instruction(&io, &state); + assert!(changed.contains("entry changed after finish"), "{changed}"); + assert!(changed.contains("build-phase finish")); + // Reporting status does not reopen phases or silently sign the new bytes. + assert_eq!(state["phases"]["review"]["status"], "closed"); + assert_ne!(state["finish"]["artifactSha256"], json!(crate::completion::artifact_hash(&ws.path, &state))); + std::fs::remove_file(ws.path.join("index.html")).unwrap(); + assert!(next_instruction(&io, &state).contains("cannot be verified")); +} + +#[test] +fn native_ship_rechecks_final_page_instead_of_signing_stale_phase_passes() { + let ws = Workspace::new(); + ws.write("index.html", b"
Edited during final review
"); + let mut io = ws.io(); + let mut state = json!({"phase":"review", "capturePolicy":"native-html-v1", + "artifact":"index.html", "comp":"comp.png", "phases":{}, + "finish":{"disposition":"ship", "artifactSha256":"old"}}); + for phase in PHASES { + state["phases"][phase] = json!({"status":"closed", "attempts":1, "gate":{"ok":true}}); + } + save_state(&io, &state); + // Even apparently successful saved gates cannot stand in for a native renderer. + assert_eq!(run(&["finish", "--disposition", "ship"].map(String::from), &mut io, &no_organic_scan), 2); + let state = load_state(&io).unwrap(); + assert_eq!(state["phase"], "responsive"); + assert_eq!(state["phases"]["responsive"]["status"], "open"); + assert_eq!(state["phases"]["responsive"]["gate"]["ok"], false); + assert_eq!(state["finish"]["disposition"], "fix"); + assert_ne!(crate::completion::report(&ws.path, Some(&state), None)["status"], "complete"); +} + +#[test] +fn ship_refuses_a_missing_required_phase() { + let ws = Workspace::new(); + let mut io = ws.io(); + let state = json!({"phase":"review","phases":{"review":{"status":"open"}},"finish":null}); + save_state(&io, &state); + assert_eq!(run(&["finish", "--disposition", "ship"].map(String::from), &mut io, &no_organic_scan), 2); + assert!(load_state(&io).unwrap()["finish"].is_null()); +} + #[test] fn delegation_is_not_authority_to_override_comp() { for reason in [ @@ -38,6 +118,60 @@ fn stall_feedback_does_not_rebuild_a_nonblocking_plate() { struct Workspace { path: PathBuf, } + +#[test] +fn crop_command_reports_invalid_reference_and_preserves_raw_diagnostic() { + let ws = Workspace::new(); + let comp = r::create_image(16, 16, [70, 80, 90, 255]); + ws.write("comp.png", &png_io::encode_png(&comp, &[]).unwrap()); + let mut spec = json!({"comp":"comp.png","regions":[ + {"id":"art","kind":"plate","medium":"raster","px":{"x":0,"y":0,"w":16,"h":16}}, + {"id":"nav","kind":"chrome","px":{"x":0,"y":0,"w":16,"h":16}}]}); + ws.write(SPEC_PATH, util::json_pretty(&spec).as_bytes()); + let mut io = ws.io(); + let args = ["--crop", "art", "--out", "crop.png"].map(String::from); + assert_eq!(crate::comp_spec::run(&args, &mut io), 2); + assert!(!ws.path.join("crop.png").exists()); + let mut raw_args = args.to_vec(); + raw_args.push("--raw".into()); + assert_eq!(crate::comp_spec::run(&raw_args, &mut io), 0); + let raw = png_io::decode_png(&std::fs::read(ws.path.join("crop.png")).unwrap()).unwrap(); + assert_eq!(raw.image.data, comp.data); + assert_eq!(raw.text.get("impeccable:crop-of").unwrap(), "comp.png#art"); + assert!(!raw.text.contains_key("impeccable:reference-audit")); + + spec["regions"][1]["container"] = json!(true); + ws.write(SPEC_PATH, util::json_pretty(&spec).as_bytes()); + assert_eq!(crate::comp_spec::run(&args, &mut io), 0); + let prepared = png_io::decode_png(&std::fs::read(ws.path.join("crop.png")).unwrap()).unwrap(); + assert_eq!(prepared.image.data, comp.data); + let audit: Value = serde_json::from_str(prepared.text.get("impeccable:reference-audit").unwrap()).unwrap(); + assert_eq!(audit["ignoredContainers"], json!(["nav"])); + assert_eq!(audit["remainingPixels"], 256); +} + +#[test] +fn completely_excluded_reference_is_a_spec_problem_not_an_asset_score() { + let ws = Workspace::new(); + let mut comp = r::create_image(32, 32, [230,220,200,255]); + r::fill_rect(&mut comp, 8., 8., 16., 16., [40.,60.,80.,255.]); + ws.write("comp.png", &png_io::encode_png(&comp, &[]).unwrap()); + let asset = r::create_image(64, 64, [230,220,200,255]); + ws.write("art.png", &png_io::encode_png(&asset, &[]).unwrap()); + let spec = json!({"comp":"comp.png","regions":[ + {"id":"art","kind":"plate","medium":"raster","plate":"art.png", + "px":{"x":0,"y":0,"w":32,"h":32},"palette":[{"hex":"#e6dcc8"}]}, + {"id":"oversized-nav","kind":"chrome","medium":"code","px":{"x":0,"y":0,"w":32,"h":32}} + ]}); + ws.write(SPEC_PATH, util::json_pretty(&spec).as_bytes()); + let gate = gate_plates(&ws.io()); + assert!(!gate.ok); + assert!(gate.reasons.iter().any(|s|s.contains("reference") && s.contains("oversized-nav")), "{:?}", gate.reasons); + assert!(!gate.reasons.iter().any(|s|s.contains("regenerate")), "{:?}", gate.reasons); + let plate = &gate.plates.as_ref().unwrap()[0]; + assert!(plate["score"].is_null()); + assert_eq!(plate["reference"]["excludedPixels"], 1024); +} impl Workspace { fn new() -> Self { static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); @@ -75,9 +209,13 @@ fn plate_approval_is_bound_to_current_asset_region_and_comp() { let spec = json!({"comp":"comp.png", "regions":[region.clone()]}); let receipt = json!({"id":"art", "status":"ok", "score":0.81, "file":"art.png", "assetHash":sha256_file(&io,"art.png"), "compHash":sha256_file(&io,"comp.png"), - "regionHash":sha256_bytes(util::json_pretty(®ion).as_bytes())}); + "regionHash":sha256_bytes(util::json_pretty(®ion).as_bytes()), + "referenceHash":plate_reference_hash(&spec)}); let mut state = json!({"plates":{"art":receipt}}); assert!(plate_receipt_current(&io, &state, &spec, ®ion)); + let mut changed_spec = spec.clone(); + changed_spec["regions"].as_array_mut().unwrap().push(json!({"id":"new-overlay","kind":"control","px":{"x":0,"y":0,"w":5,"h":5}})); + assert!(!plate_receipt_current(&io, &state, &changed_spec, ®ion), "neighbouring exclusions invalidate approval"); ws.write("art.png", b"replacement"); assert!(!plate_receipt_current(&io, &state, &spec, ®ion)); ws.write("art.png", b"accepted asset bytes"); @@ -133,7 +271,7 @@ fn copied_comp_does_not_earn_an_ok_plate_receipt_or_advance() { artifact: None, }; for _ in 0..5 { - let result = advance(&io, &mut state, false, None, &opts, &no_organic_scan); + let result = advance(&io, &mut state, false, None, &opts, &no_organic_scan, None); assert!(!result.ok); assert_eq!(state["phase"], "plates"); assert_eq!(state["plates"]["art"]["status"], "invalid"); @@ -196,7 +334,7 @@ fn accepted_file_hidden_in_render_still_blocks_hero() { ws.write(SPEC_PATH, util::json_pretty(&spec).as_bytes()); let io = ws.io(); // Model an already accepted current asset; rendered presence is still required. - let receipt = json!({"status":"ok","score":0.9,"file":"art.png","assetHash":sha256_file(&io,"art.png"),"compHash":sha256_file(&io,"comp.png"),"regionHash":sha256_bytes(util::json_pretty(®ion).as_bytes())}); + let receipt = json!({"status":"ok","score":0.9,"file":"art.png","assetHash":sha256_file(&io,"art.png"),"compHash":sha256_file(&io,"comp.png"),"regionHash":sha256_bytes(util::json_pretty(®ion).as_bytes()),"referenceHash":plate_reference_hash(&spec)}); let mut state = json!({"comp":"comp.png","plates":{"art":receipt},"phases":{"hero":{"attempts":0}}}); for _ in 0..4 { @@ -207,8 +345,7 @@ fn accepted_file_hidden_in_render_still_blocks_hero() { HERO_MIN, "diff", Some("index.html"), - &no_organic_scan, - ); + &no_organic_scan, None); assert!(!g.ok); assert!( g.reasons.iter().any(|r| r.contains("missing")), @@ -257,8 +394,7 @@ fn preflight_failure_replaces_stale_success_report() { HERO_MIN, "diff", None, - &no_organic_scan, - ); + &no_organic_scan, None); assert!(!gate.ok); let report: Value = serde_json::from_slice(&std::fs::read(ws.path.join("diff/report.json")).unwrap()).unwrap(); @@ -302,6 +438,43 @@ fn simple_hero_workspace() -> (Workspace, Value) { (ws, json!({"comp":"comp.png","phases":{"hero":{}}})) } +#[test] +fn responsive_rejects_a_contradicted_control_even_above_the_overall_bar() { + let ws = Workspace::new(); + let mut comp = r::create_image(200, 120, [230, 220, 200, 255]); + r::fill_rect(&mut comp, 120., 84., 60., 24., [20., 50., 80., 255.]); + for y in (86..106).step_by(3) { + r::fill_rect(&mut comp, 124., y as f64, 52., 1., [240., 240., 240., 255.]); + } + let mut changed = comp.clone(); + r::fill_rect(&mut changed, 120., 84., 60., 24., [190., 30., 100., 255.]); + for x in (122..178).step_by(3) { + r::fill_rect(&mut changed, x as f64, 86., 1., 20., [240., 240., 240., 255.]); + } + ws.write("comp.png", &png_io::encode_png(&comp, &[]).unwrap()); + ws.write(SPEC_PATH, util::json_pretty(&json!({"comp":"comp.png","regions":[{ + "id":"inquiry","kind":"control","medium":"semantic", + "box":{"x":0.6,"y":0.7,"w":0.3,"h":0.2}, + "px":{"x":120,"y":84,"w":60,"h":24}}]})).as_bytes()); + let mut state = json!({"comp":"comp.png","phases":{}}); + for (image, accepted) in [(&comp, true), (&changed, false)] { + let png = png_io::encode_png(image, &[]).unwrap(); + ws.write(".impeccable/review/desktop.png", &png); + ws.write(".impeccable/review/mobile.png", &png); + let gate = gate_responsive(&ws.io(), &mut state, RESPONSIVE_MIN, "diff", None); + let report: Value = serde_json::from_slice(&std::fs::read(ws.path.join("diff/report.json")).unwrap()).unwrap(); + assert!(report["overall"].as_f64().unwrap() >= RESPONSIVE_MIN); + if !accepted { + assert_eq!(report["regions"][0]["rawVerdict"], "contradicted"); + } + assert_eq!(gate.ok, accepted, "{report}"); + assert_eq!(report["regions"][0]["blocking"], !accepted); + if !accepted { + assert!(gate.reasons.iter().any(|reason| reason.contains("inquiry (control) is contradicted"))); + } + } +} + #[test] fn failed_evidence_writes_cannot_publish_success() { for blocked_file in ["regions/button.png", "raw-report.json"] { @@ -313,8 +486,7 @@ fn failed_evidence_writes_cannot_publish_success() { HERO_MIN, "diff", Some("index.html"), - &no_organic_scan, - ); + &no_organic_scan, None); assert!(g.ok, "fixture: {:?}", g.reasons); let blocked = ws.path.join("diff").join(blocked_file); std::fs::remove_file(&blocked).unwrap(); @@ -326,8 +498,7 @@ fn failed_evidence_writes_cannot_publish_success() { HERO_MIN, "diff", Some("index.html"), - &no_organic_scan, - ); + &no_organic_scan, None); assert!(!g.ok, "write failure must block: {blocked_file}"); assert_no_current_measurements(&ws); let report: Value = @@ -368,7 +539,7 @@ fn responsive_revalidates_legacy_or_changed_plate_receipts() { util::json_pretty(&json!({"comp":"comp.png","regions":[region]})).as_bytes(), ); state["plates"] = json!({"art":{"status":"ok","score":0.9}}); - let g = gate_responsive(&ws.io(), &mut state, RESPONSIVE_MIN, "diff"); + let g = gate_responsive(&ws.io(), &mut state, RESPONSIVE_MIN, "diff", None); assert!(!g.ok, "a missing asset cannot inherit legacy approval"); assert!( g.reasons.iter().any(|r| r.contains("plate missing")), @@ -418,7 +589,7 @@ fn responsive_failures_replace_previous_success_evidence() { let image = std::fs::read(ws.path.join("comp.png")).unwrap(); ws.write(".impeccable/review/desktop.png", &image); ws.write(".impeccable/review/mobile.png", &image); - let good = gate_responsive(&ws.io(), &mut state, RESPONSIVE_MIN, "diff"); + let good = gate_responsive(&ws.io(), &mut state, RESPONSIVE_MIN, "diff", None); assert!(good.ok, "{:?}", good.reasons); let report: Value = serde_json::from_slice(&std::fs::read(ws.path.join("diff/report.json")).unwrap()) @@ -432,7 +603,7 @@ fn responsive_failures_replace_previous_success_evidence() { std::fs::remove_file(ws.path.join("diff/regions/button.png")).unwrap(); std::fs::create_dir(ws.path.join("diff/regions/button.png")).unwrap(); } - let bad = gate_responsive(&ws.io(), &mut state, RESPONSIVE_MIN, "diff"); + let bad = gate_responsive(&ws.io(), &mut state, RESPONSIVE_MIN, "diff", None); assert!(!bad.ok); assert_no_current_measurements(&ws); let report: Value = @@ -459,9 +630,9 @@ fn failed_preflight_clears_complete_evidence_for_both_gates() { ws.write(".impeccable/review/desktop.png", &image); ws.write(".impeccable/review/mobile.png", &image); let run = |state: &mut Value| if responsive { - gate_responsive(&ws.io(), state, RESPONSIVE_MIN, "diff") + gate_responsive(&ws.io(), state, RESPONSIVE_MIN, "diff", None) } else { - gate_hero(&ws.io(), state, "comp.png", HERO_MIN, "diff", Some("index.html"), &no_organic_scan) + gate_hero(&ws.io(), state, "comp.png", HERO_MIN, "diff", Some("index.html"), &no_organic_scan, None) }; assert!(run(&mut state).ok); ws.write("diff/regions/retired.png", &image); @@ -478,7 +649,7 @@ fn failed_preflight_clears_complete_evidence_for_both_gates() { fn successful_repeat_removes_retired_region_crops() { let (ws, mut state) = simple_hero_workspace(); ws.write("diff/regions/retired.png", b"old crop"); - let gate = gate_hero(&ws.io(), &mut state, "comp.png", HERO_MIN, "diff", Some("index.html"), &no_organic_scan); + let gate = gate_hero(&ws.io(), &mut state, "comp.png", HERO_MIN, "diff", Some("index.html"), &no_organic_scan, None); assert!(gate.ok, "{:?}", gate.reasons); assert!(!ws.path.join("diff/regions/retired.png").exists()); assert!(ws.path.join("diff/regions/button.png").is_file()); @@ -491,7 +662,7 @@ fn artifact_cleanup_does_not_follow_region_directory_symlinks() { ws.write("elsewhere/keep.png", b"unrelated image"); std::fs::create_dir_all(ws.path.join("diff")).unwrap(); std::os::unix::fs::symlink(ws.path.join("elsewhere"), ws.path.join("diff/regions")).unwrap(); - let gate = gate_hero(&ws.io(), &mut state, "comp.png", HERO_MIN, "diff", Some("index.html"), &no_organic_scan); + let gate = gate_hero(&ws.io(), &mut state, "comp.png", HERO_MIN, "diff", Some("index.html"), &no_organic_scan, None); assert!(gate.ok, "{:?}", gate.reasons); assert_eq!(std::fs::read(ws.path.join("elsewhere/keep.png")).unwrap(), b"unrelated image"); assert!(!ws.path.join("elsewhere/button.png").exists()); @@ -506,7 +677,7 @@ fn artifact_cleanup_failure_blocks_the_gate() { ws.write("diff/regions/retired.png", b"stale crop"); let dir = ws.path.join("diff/regions"); std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap(); - let gate = gate_hero(&ws.io(), &mut state, "comp.png", HERO_MIN, "diff", Some("index.html"), &no_organic_scan); + let gate = gate_hero(&ws.io(), &mut state, "comp.png", HERO_MIN, "diff", Some("index.html"), &no_organic_scan, None); if dir.exists() { std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); } assert!(!gate.ok); assert!(gate.reasons.iter().any(|r| r.contains("cannot clear comparison artifacts")), "{:?}", gate.reasons); @@ -519,3 +690,45 @@ fn artifact_cleanup_failure_blocks_the_gate() { assert_eq!(report["artifactCleanup"]["quarantine"]["errors"], json!([])); std::fs::set_permissions(quarantine, std::fs::Permissions::from_mode(0o755)).unwrap(); } + + +#[test] +fn transformed_comp_crop_cannot_become_a_plate_by_drifting_below_similarity_threshold() { + let ws = Workspace::new(); + let mut reference = r::create_image(32, 32, [240, 230, 210, 255]); + r::fill_rect(&mut reference, 2., 3., 10., 20., [20., 70., 140., 255.]); + ws.write("comp.png", &png_io::encode_png(&reference, &[]).unwrap()); + // Deliberately different pixels: the crop marker is evidence independently + // of a perceptual-similarity threshold or a new embedded generation prompt. + let transformed = r::create_image(64, 64, [30, 100, 60, 255]); + ws.write("plate.png", &png_io::encode_png(&transformed, &[ + ("impeccable:crop-of".into(), "comp.png#photo".into()), + ("impeccable:prompt".into(), "A freshly generated photograph".into()), + ]).unwrap()); + ws.write(SPEC_PATH, util::json_pretty(&json!({"comp":"comp.png","regions":[ + {"id":"photo","kind":"plate","medium":"raster","plate":"plate.png", + "px":{"x":0,"y":0,"w":32,"h":32}} + ]})).as_bytes()); + let gate = gate_plates(&ws.io()); + assert!(!gate.ok); + assert!(gate.reasons.iter().any(|r|r.contains("records a comp crop")), "{:?}", gate.reasons); +} + +#[test] +fn caller_supplied_fake_metadata_cannot_bypass_the_crop_check() { + let ws = Workspace::new(); + let mut reference = r::create_image(32, 32, [240, 230, 210, 255]); + r::fill_rect(&mut reference, 2., 3., 10., 20., [20., 70., 140., 255.]); + ws.write("comp.png", &png_io::encode_png(&reference, &[]).unwrap()); + ws.write("plate.png", &png_io::encode_png(&reference, &[ + ("impeccable:fake".into(), "1".into()), + ("impeccable:prompt".into(), "A generated production plate".into()), + ]).unwrap()); + ws.write(SPEC_PATH, util::json_pretty(&json!({"comp":"comp.png","regions":[ + {"id":"photo","kind":"plate","medium":"raster","plate":"plate.png", + "px":{"x":0,"y":0,"w":32,"h":32}} + ]})).as_bytes()); + let gate = gate_plates(&ws.io()); + assert!(!gate.ok); + assert!(gate.reasons.iter().any(|reason| reason.contains("is the comp crop")), "{:?}", gate.reasons); +} diff --git a/crates/comp-verbs/src/comp_spec.rs b/crates/comp-verbs/src/comp_spec.rs index 3d4c61b32..0a4700471 100644 --- a/crates/comp-verbs/src/comp_spec.rs +++ b/crates/comp-verbs/src/comp_spec.rs @@ -604,11 +604,49 @@ fn hex_to_rgb(hex: &str) -> Option<[u8; 3]> { ]) } -/// JS: plateReference(comp, spec, region). +/// The exclusions are evidence about the reference, not an asset verdict. +/// Count the union of rasterized rectangles, including already-ground pixels. +pub struct PlateReference { + pub image: Image, + pub excluded_pixels: usize, + pub total_pixels: usize, + pub excluded_regions: Vec, + pub ignored_containers: Vec, +} + +impl PlateReference { + pub fn fully_excluded(&self) -> bool { + self.excluded_pixels == self.total_pixels + } + + pub fn audit(&self) -> Value { + json!({"policy":"plate-reference-v2", "excludedPixels":self.excluded_pixels, + "totalPixels":self.total_pixels, "remainingPixels":self.total_pixels-self.excluded_pixels, + "excludedFraction":self.excluded_pixels as f64 / self.total_pixels.max(1) as f64, + "fullyExcluded":self.fully_excluded(), "regions":self.excluded_regions, + "ignoredContainers":self.ignored_containers}) + } + + pub fn issue(&self, id: &str) -> Option { + if !self.fully_excluded() { return None; } + let ids = self.excluded_regions.iter().filter_map(|r|r["id"].as_str()).collect::>().join(", "); + Some(format!("reference for {id} has no visible pixels after excluding {ids}; correct the overlapping region geometry or container roles in the comp spec before evaluating this asset")) + } +} + +/// JS: plateReference(comp, spec, region). Kept for pure image consumers. pub fn plate_reference(comp: &Image, spec: &Value, region: &Value) -> Image { + prepare_plate_reference(comp, spec, region).image +} + +pub fn prepare_plate_reference(comp: &Image, spec: &Value, region: &Value) -> PlateReference { let px = |k: &str| region.pointer(&format!("/px/{k}")).and_then(Value::as_f64).unwrap_or(0.0); let (rx, ry, rw, rh) = (px("x"), px("y"), px("w"), px("h")); let mut c = r::crop(comp, rx, ry, rw, rh); + let total_pixels = c.width * c.height; + let mut excluded = vec![false; total_pixels]; + let mut excluded_regions = Vec::new(); + let mut ignored_containers = Vec::new(); let ground = region .get("palette") .and_then(Value::as_array) @@ -634,10 +672,25 @@ pub fn plate_reference(comp: &Image, spec: &Value, region: &Value) -> Image { if ox2 <= ox || oy2 <= oy { continue; } + // Containers describe layout/background extent, not foreground ink. + // Their actual child text/control regions remain independently masked. + if other.get("container").and_then(Value::as_bool) == Some(true) { + ignored_containers.push(oid.to_string()); + continue; + } + let rect = r::clamp_rect(&c, ox, oy, ox2-ox, oy2-oy); + if rect.w == 0 || rect.h == 0 { continue; } + for y in rect.y..rect.y+rect.h { + for x in rect.x..rect.x+rect.w { excluded[y*c.width+x] = true; } + } + excluded_regions.push(json!({"id":oid,"kind":okind, + "cropPx":{"x":rect.x,"y":rect.y,"w":rect.w,"h":rect.h}, + "pixels":rect.w*rect.h})); r::fill_rect(&mut c, ox, oy, ox2 - ox, oy2 - oy, [ground[0] as f64, ground[1] as f64, ground[2] as f64, 255.0]); } } - c + PlateReference { image:c, excluded_pixels:excluded.iter().filter(|v|**v).count(), + total_pixels, excluded_regions, ignored_containers } } /// JS: platePrompt(spec, region). @@ -854,8 +907,15 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 { } }; let medium = region.get("medium").and_then(Value::as_str).unwrap_or(""); + let mut reference_audit = None; let mut c = if medium == "raster" && !flag(argv, "raw") { - plate_reference(&comp, &spec, ®ion) + let reference = prepare_plate_reference(&comp, &spec, ®ion); + if let Some(issue) = reference.issue(id) { + io.err(&format!("comp-spec: {issue}.\n")); + return 2; + } + reference_audit = Some(reference.audit()); + reference.image } else { let px = |k: &str| region.pointer(&format!("/px/{k}")).and_then(Value::as_f64).unwrap_or(0.0); r::crop(&comp, px("x"), px("y"), px("w"), px("h")) @@ -870,7 +930,10 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 { if let Some(parent) = out_path.parent() { let _ = std::fs::create_dir_all(parent); } - let text = vec![("impeccable:crop-of".to_string(), format!("{comp_file}#{id}"))]; + let mut text = vec![("impeccable:crop-of".to_string(), format!("{comp_file}#{id}"))]; + if let Some(audit) = reference_audit { + text.push(("impeccable:reference-audit".into(), audit.to_string())); + } match png_io::encode_png(&c, &text) { Ok(bytes) => { let _ = std::fs::write(&out_path, bytes); @@ -971,3 +1034,57 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 { let _ = r4f(0.0); // silence unused if optimized away 0 } + +#[cfg(test)] +mod reference_tests { + use super::*; + + fn fixture() -> (Image, Value) { + let mut comp = r::create_image(16, 16, [230, 220, 210, 255]); + r::fill_rect(&mut comp, 4., 4., 8., 8., [30., 70., 110., 255.]); + let region = json!({"id":"art","kind":"plate","medium":"raster", + "px":{"x":0,"y":0,"w":16,"h":16},"palette":[{"hex":"#e6dcd2"}]}); + (comp, region) + } + + #[test] + fn container_background_preserves_art_but_foreground_control_still_masks() { + let (comp, region) = fixture(); + let container = json!({"id":"background","kind":"chrome","container":true, + "px":{"x":0,"y":0,"w":16,"h":16}}); + let spec = json!({"regions":[region,container]}); + assert_eq!(plate_reference(&comp, &spec, &spec["regions"][0]).data, comp.data); + let mut spec = spec; + spec["regions"].as_array_mut().unwrap().push(json!({"id":"button","kind":"control", + "px":{"x":0,"y":0,"w":8,"h":8}})); + let mut expected = comp.clone(); + r::fill_rect(&mut expected, 0., 0., 8., 8., [230.,220.,210.,255.]); + assert_eq!(plate_reference(&comp, &spec, &spec["regions"][0]).data, expected.data); + } + + #[test] + fn exclusion_audit_counts_union_and_clips_to_crop() { + let (comp, region) = fixture(); + let spec = json!({"regions":[region, + {"id":"left","kind":"text","px":{"x":-8,"y":0,"w":20,"h":16}}, + {"id":"right","kind":"control","px":{"x":8,"y":0,"w":20,"h":16}}]}); + let reference = prepare_plate_reference(&comp, &spec, &spec["regions"][0]); + assert_eq!(reference.excluded_pixels, 256); + assert_eq!(reference.excluded_regions[0]["pixels"], 192); + assert_eq!(reference.excluded_regions[1]["pixels"], 128); + assert!(reference.fully_excluded()); + assert_eq!(reference.audit()["remainingPixels"], 0); + assert!(reference.issue("art").unwrap().contains("left, right")); + } + + #[test] + fn uniform_reference_without_exclusions_is_not_an_exclusion_failure() { + let (_, region) = fixture(); + let comp = r::create_image(16, 16, [230, 220, 210, 255]); + let spec = json!({"regions":[region]}); + let reference = prepare_plate_reference(&comp, &spec, &spec["regions"][0]); + assert_eq!(reference.excluded_pixels, 0); + assert!(!reference.fully_excluded()); + assert!(reference.issue("art").is_none()); + } +} diff --git a/crates/comp-verbs/src/completion.rs b/crates/comp-verbs/src/completion.rs new file mode 100644 index 000000000..69d99b121 --- /dev/null +++ b/crates/comp-verbs/src/completion.rs @@ -0,0 +1,84 @@ +//! Read-only comp completion status shared by the CLI and native hooks. +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +pub const PHASES: [&str; 8] = ["comps", "spec", "plates", "hero", "sections", "motion", "responsive", "review"]; + +pub fn artifact_path(root: &Path, state: &Value) -> Option { + let relative = state.get("artifact")?.as_str()?; + if relative.is_empty() { return None; } + let path = Path::new(relative); + let root = root.canonicalize().ok()?; + let absolute = if path.is_absolute() { path.to_path_buf() } else { root.join(path) }; + let absolute = absolute.canonicalize().ok()?; + absolute.starts_with(&root).then_some(absolute) +} + +pub fn artifact_hash(root: &Path, state: &Value) -> Option { + let path = artifact_path(root, state)?; + let bytes = std::fs::read(path).ok()?; + Some(format!("{:x}", Sha256::digest(bytes))) +} + +pub fn open_phases(state: &Value, include_review: bool) -> Vec<&'static str> { + PHASES.iter().copied().filter(|phase| { + (include_review || *phase != "review") && !matches!( + state.pointer(&format!("/phases/{phase}/status")).and_then(Value::as_str), + Some("closed" | "skipped") + ) + }).collect() +} + +pub fn report(root: &Path, state: Option<&Value>, session_id: Option<&str>) -> Value { + let Some(state) = state else { + return json!({"tool":"build-completion", "version":1, "status":"not-applicable", "canContinue":false}); + }; + let phases = open_phases(state, true); + let disposition = state.pointer("/finish/disposition").and_then(Value::as_str); + let owner = state.get("sessionId").and_then(Value::as_str).filter(|s| !s.is_empty()); + let scope = match (owner, session_id.filter(|s| !s.is_empty())) { + (Some(a), Some(b)) if a == b => "current-session", + (Some(_), Some(_)) => "other-session", + _ => "unknown", + }; + let current_hash = artifact_hash(root, state); + let recorded_hash = state.pointer("/finish/artifactSha256").and_then(Value::as_str); + let unchanged = recorded_hash.zip(current_hash.as_deref()).map(|(a,b)| a == b); + let status = if phases.is_empty() && disposition == Some("ship") { + match unchanged { + Some(true) => "complete", + Some(false) => "changed-after-finish", + None => "unverified", + } + } else { "incomplete" }; + json!({ + "tool":"build-completion", "version":1, "status":status, + "sessionScope":scope, "sessionId":owner, "buildStartedAt":state.get("startedAt"), + "artifact":state.get("artifact"), "openPhases":phases, + "disposition":disposition, "artifactUnchangedSinceFinish":unchanged, + "canContinue":scope == "current-session" && current_hash.is_some() + && matches!(status, "incomplete" | "changed-after-finish"), + "verificationScope":"entry artifact bytes and recorded phase status; dependencies retain their own gate evidence" + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn missing_phase_is_unfinished() { + assert_eq!(open_phases(&json!({"phases":{}}), false).len(), 7); + } + #[test] + fn no_state_does_not_start_a_workflow() { + assert_eq!(report(Path::new("."), None, Some("session"))["status"], "not-applicable"); + } + #[test] + fn absent_or_foreign_identity_cannot_continue() { + for session in [None, Some("other")] { + let state = json!({"sessionId":"owner","artifact":"missing.html"}); + assert_eq!(report(Path::new("."), Some(&state), session)["canContinue"], false); + } + } +} diff --git a/crates/comp-verbs/src/entry_capture.rs b/crates/comp-verbs/src/entry_capture.rs new file mode 100644 index 000000000..0bd1f82c7 --- /dev/null +++ b/crates/comp-verbs/src/entry_capture.rs @@ -0,0 +1,34 @@ +//! Native capture boundary for build-phase. Receipts are audit output, never inputs. +use crate::asset_capture::AssetCapture; +use serde_json::Value; +use std::path::PathBuf; + +#[derive(Clone, Copy)] +pub enum EntryStage { + Hero, + Responsive, +} +pub struct EntryRequest { + pub root: PathBuf, + pub artifact: String, + pub spec: String, + pub reference: String, + pub stage: EntryStage, +} +pub struct FrameEvidence { + pub name: String, + pub png: Vec, + pub regions: Vec, +} +pub struct EntryEvidence { + pub report: Value, + pub frames: Vec, +} +pub trait CapturedEntry { + fn evidence(&self) -> &EntryEvidence; + /// Recheck original bytes while this in-process capture still owns its snapshot. + fn verify_current(&self) -> Result<(), String>; +} +pub trait EntryRenderer { + fn capture_entry(&self, request: &EntryRequest) -> Result, String>; +} diff --git a/crates/comp-verbs/src/lib.rs b/crates/comp-verbs/src/lib.rs index c8d2d6dc7..a0b3d06d7 100644 --- a/crates/comp-verbs/src/lib.rs +++ b/crates/comp-verbs/src/lib.rs @@ -15,6 +15,7 @@ //! never committed to the engine repo. See [`font_match`]. pub mod build_phase; +pub mod completion; pub mod comp_diff; pub mod comp_spec; pub mod font_match; @@ -45,3 +46,7 @@ pub fn run_font_match(argv: &[String], io: &mut Io, renderer: &mut dyn font_matc pub fn run_build_phase(argv: &[String], io: &mut Io, organic_scan: build_phase::OrganicScan) -> i32 { build_phase::run(argv, io, organic_scan) } + +pub mod asset_capture; + +pub mod entry_capture; diff --git a/crates/context/Cargo.toml b/crates/context/Cargo.toml index bc370e250..d3a919d41 100644 --- a/crates/context/Cargo.toml +++ b/crates/context/Cargo.toml @@ -13,6 +13,7 @@ core-registry = [] [dependencies] impeccable-common = { workspace = true } +impeccable-comp = { workspace = true } impeccable-core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/context/assets/component-review.js b/crates/context/assets/component-review.js new file mode 100644 index 000000000..18ccc7deb --- /dev/null +++ b/crates/context/assets/component-review.js @@ -0,0 +1,134 @@ +(()=>{function te(n,i){let a=i?.changes[n],o=i?.feedback?.[n],r=o?.decision??(i?.submitted?i.draft.decisions[n]:void 0),g=a?.kind==="unchanged"&&(a.carried??(i?.submitted&&r?.action==="approve"))===!0;return{change:a,prior:r,feedbackRound:o?.round??i?.packet.round,carried:g,label:a?.kind==="added"?"New component":a?.kind==="changed"?"Review again":g?"Approval kept":r?.action==="revise"?"Changes still requested":"Awaiting review"}}function I(n,i,a){let o=i.decisions[n.id],r=o?.revision===n.revision?o:void 0,g=te(n.id,a);if(r?.action==="approve")return{kind:"approved",label:g.carried?"Approval kept":"Approved",priority:3};if(r?.action==="revise")return{kind:"feedback",label:"Feedback ready",priority:2};return{kind:"pending",label:g.change?.kind==="changed"?"Review again":g.change?.kind==="added"?"New · review needed":"Not reviewed",priority:g.change?.kind==="changed"||g.change?.kind==="added"?0:1}}function Ye(n){return{packetRevision:n.revision,decisions:{},missing:[],inventoryConfirmed:!1}}function pi(n){return Object.values(n).every(Number.isFinite)&&n.x>=0&&n.y>=0&&n.w>0&&n.h>0&&n.x+n.w<=1.00001&&n.y+n.h<=1.00001}function pe(n,i){let a=n.components.map((h)=>i.decisions[h.id]?.revision===h.revision?i.decisions[h.id]:void 0),o=a.filter((h)=>h?.action==="approve").length,r=a.filter((h)=>h?.action==="revise").length,g=a.length-o-r,l=r>0||i.missing.length>0;return{approved:o,revisions:r,pending:g,hasFeedback:l,canSubmit:i.packetRevision===n.revision&&i.missing.every((h)=>h.name.trim()&&pi(h.box))&&(l||!g&&i.inventoryConfirmed)}}function De(n,i){let a={...i.decisions};for(let o of n.components)if(!a[o.id]||a[o.id].revision!==o.revision)a[o.id]={revision:o.revision,action:"approve",feedback:"",split:!1};return{...i,decisions:a}}function Ze(n,i){if(!pe(n,i).canSubmit)throw Error("Review is incomplete or stale");return{schemaVersion:1,requestId:n.id,...structuredClone(i)}}function ae(n){let i=n.preview.kind==="page"||n.preview.sourceKind==="page",a=n.preview.sourceKind==="page";return{code:i,captured:a,label:i?n.medium.match(/html|css|svg/i)?n.medium:"HTML / CSS / SVG":"Raster",caption:i?a?"Rendered component":"Live component":"Produced asset",fileLabel:a?"Open captured preview":"Open source image"}}function ce(n,i,a,o,r){let g=r==="fit"?Math.min(a/n,o/i):r;return{scale:g,width:n*g,height:i*g}}var Re=` +:host{height:var(--component-review-height,100dvh);min-height:0;overflow:hidden} +.review{height:100%;max-width:none;min-height:0;padding:0;display:flex;flex-direction:column;overflow:hidden;background:var(--color-bg,#fafafa)} +.review>header{flex-shrink:0;padding:16px 24px;margin:0;border-bottom:1px solid var(--line);gap:16px}.review>header>div{min-width:0}.review h1{font-size:30px;line-height:1}.review>header p{font-size:12px;margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.review .badge{font-size:11px} +.review>.preview-note{flex-shrink:0;margin:0;padding:8px 24px;font-size:11px}.review>.round-summary{flex-shrink:0;margin:0;padding:8px 24px;border-top:0;gap:6px 16px;max-height:120px;overflow:auto}.round-summary p{font-size:12px;gap:6px 14px}.round-summary button{font-size:12px;min-height:32px;padding:5px 10px} +.review>.workbench{flex:1;min-height:0;align-items:stretch;padding:20px 24px;gap:32px;overflow:hidden;grid-template-columns:minmax(0,1.12fr) minmax(0,1fr)}.reference{display:flex;flex-direction:column;min-height:0;width:100%;max-width:none;margin:0}.reference>.section-head{flex-shrink:0;margin-bottom:10px}.map-space{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;background:#eeefec;border:1px solid var(--line);overflow:hidden}.map{flex-shrink:0;max-width:100%;max-height:100%}.map-legend{flex-shrink:0;margin-top:9px;gap:6px 14px}.map-caption{flex-shrink:0;min-height:0;padding-top:6px;font-size:10px}.map-caption button{display:inline-block;margin:0 0 0 8px;min-height:28px;padding:3px 7px} +.workbench>.inspector{height:100%;min-height:0;padding:0;border:0}.inspector>.section-head{margin-bottom:10px;min-height:32px}.section-head h2{font-size:16px}.inspection-content{padding-bottom:8px}.review-form{max-height:55%;overflow-y:auto;scrollbar-width:thin;scrollbar-gutter:stable}.material{min-height:28px}.material strong{font-size:13px}.compare-toolbar{margin-bottom:12px}.previous-feedback{padding:10px 12px}.repair-context{margin-bottom:12px}.preview-round{margin-bottom:10px}.decision-title p{font-size:11px}.decisions>button{min-height:40px;font-size:13px}.decision-title strong{font-size:13px}.view-controls{margin-top:8px}.component-details{height:auto;max-height:80px} +.review>.inventory-section{height:236px;flex-shrink:0;display:flex;flex-direction:column;min-height:0;margin:0;padding:10px 24px 8px;border-top:1px solid var(--line);background:var(--color-panel,#f5f5f2);overflow:hidden}.inventory-section>.section-head{margin:0 0 8px;min-height:36px;flex-shrink:0;gap:10px}.inventory-section h2{font-size:14px}.inventory{flex:1;min-height:0;align-items:stretch;margin:0;padding:3px 3px 7px;overflow-x:auto;overflow-y:hidden}.inventory.all{overflow:auto;align-items:start;grid-auto-rows:160px}.inventory .item{flex:0 0 136px;padding:8px;gap:3px;grid-template-rows:auto auto minmax(24px,1fr) auto;min-height:0}.inventory .item-thumb{height:46px;margin-bottom:3px}.inventory .thumb-crop{max-height:46px}.inventory .item-number{font-size:11px;min-height:16px;padding:0}.inventory .item strong{font-size:12px;min-height:26px;line-height:1.2}.inventory .state{font-size:11px;padding-top:4px}.tray-actions{display:flex;gap:6px;align-items:center}.tray-actions button{white-space:nowrap;font-size:11px;min-height:32px}.tray-actions #toggle-tray{display:flex;align-items:center;gap:6px}.tray-actions svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}.review>.inventory-section.tray-expanded{height:min(42dvh,390px)}.review>.inventory-section.tray-collapsed{height:56px;padding-block:10px}.tray-collapsed .inventory{display:none}.tray-collapsed>.section-head{margin-bottom:0} +.review>footer{flex-shrink:0;margin:0;padding:12px 24px;gap:16px;border-top:1px solid var(--line);background:var(--paper);align-items:center}.review>footer>div:first-child{display:flex;align-items:center;gap:14px;min-width:0}.review>footer .check{margin:0;max-width:240px;font-size:11px}.review>footer #approve-rest{min-height:36px;font-size:12px;max-width:210px;padding:7px 10px}.review>footer .submit-area{gap:12px}.review>footer .submit-area p{font-size:11px;max-width:24ch}.review>footer .primary{min-height:40px;font-size:13px}.mobile-panes{display:none} +/* Depth describes the shell: recessed work area, raised inspector, anchored docks. */ +.review{--workspace:#e3e6e2;--canvas:#d5dad5;--dock:#f6f7f4;--surface:#fff;background:var(--workspace)} +.review>header{position:relative;z-index:8;background:var(--surface);border-bottom-color:#d5d9d3} +.review>.round-summary,.review>.preview-note{position:relative;z-index:7;background:var(--dock);box-shadow:0 3px 6px #202b2510;border-bottom-color:#c8cfc7} +.review>.workbench{background:var(--workspace)} +.map-space{background:var(--canvas);border-color:#bdc6bd;box-shadow:inset 0 2px 7px #263d2a12;border-radius:5px} +.map{box-shadow:0 3px 10px #182a242b} +.workbench>.inspector{background:var(--surface);border-radius:6px;box-shadow:0 2px 4px #24332812,0 8px 24px #24332818;scrollbar-gutter:auto;isolation:isolate} +.inspector>.section-head{position:relative;z-index:2;margin:0;padding:12px 16px;background:var(--surface);border-bottom:1px solid #dce0da} +.inspection-content{padding:14px 12px 14px 16px;background:#fafbf9} +.inspector>.review-form{position:relative;z-index:2;padding:12px 12px 12px 16px;background:var(--surface);border-top:1px solid #cdd4ca} +.inspector[data-scroll-above=true]>.section-head{box-shadow:0 6px 8px -4px #24332838} +.inspector[data-scroll-below=true]>.review-form{box-shadow:0 -6px 8px -4px #24332838} +.inspection-content,.review-form,.inventory{scrollbar-width:auto;scrollbar-color:#8d9c91 #e3e8e1;overscroll-behavior:contain} +.inspection-content::-webkit-scrollbar,.review-form::-webkit-scrollbar,.inventory::-webkit-scrollbar{width:10px;height:10px} +.inspection-content::-webkit-scrollbar-track,.review-form::-webkit-scrollbar-track,.inventory::-webkit-scrollbar-track{background:#e3e8e1;border-radius:6px} +.inspection-content::-webkit-scrollbar-thumb,.review-form::-webkit-scrollbar-thumb,.inventory::-webkit-scrollbar-thumb{background:#8d9c91;border:2px solid #e3e8e1;border-radius:6px} +.review>.inventory-section{position:relative;z-index:8;background:var(--dock);border-top-color:#b9c3b8;box-shadow:0 -3px 5px #2433280c,0 -10px 24px #24332814} +.inventory-section>.section-head{border-bottom:1px solid #d9dfd5;padding-bottom:8px;margin-bottom:8px} +.tray-collapsed>.section-head{border:0;padding-bottom:0;margin-bottom:0} +.inventory{background:#e9ece5;border-radius:4px;padding:7px 7px 9px} +.inventory .item{background:#fafbf8} +.inventory .item.active{background:#fff} +.review>footer{position:relative;z-index:9;background:var(--surface);border-top-color:#ccd4c7;box-shadow:0 -2px 5px #2433280b} +.mobile-panes{position:relative;z-index:7;box-shadow:0 3px 6px #202b2510} +/* Utility actions share a compact icon language; decisions retain explicit labels. */ +.utility-icon{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0} +.review .icon-button{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:36px;height:36px;min-height:36px;padding:7px;text-decoration:none;border:1px solid transparent;border-radius:4px;background:transparent;color:var(--muted)} +.review .icon-button:hover{background:#e5ebe4;border-color:#b9c8bd;color:var(--teal)} +.review .icon-button[aria-pressed=true]{background:#e5eee8;color:var(--teal)} +.review .icon-button:focus-visible{outline:2px solid var(--teal);outline-offset:2px} +.review .label-icon{display:inline-flex;align-items:center;justify-content:center;gap:7px} +.review .tray-actions .utility-icon{width:18px;height:18px;stroke-width:1.6} +.material{align-items:center;margin-bottom:10px}.material .source-link{margin-left:auto;width:30px;height:30px;min-height:30px;padding:5px} +.preview-round{justify-content:flex-end;margin-bottom:10px} +.compare-toolbar{margin-bottom:10px} +.view-controls{min-height:0}.view-controls>.background-options{margin-left:auto;gap:2px}.view-controls .swatch-button{display:flex;align-items:center;justify-content:center;min-width:32px;height:32px;padding:5px} +.background-swatch{display:block;width:20px;height:20px;border:1px solid #9ba99d;border-radius:2px;pointer-events:none}.background-swatch.checker{background-size:8px 8px}.page-swatch{background:var(--comp-background,#eee)} +.review>footer .submit-area p{max-width:25ch} +@media(max-width:1100px) and (min-width:801px){.review>header{padding:12px 20px}.review>.workbench{padding:16px 20px;gap:24px}.review>.round-summary{padding-inline:20px}.round-summary p{max-width:calc(100% - 52px)}.round-summary p>span{font-size:11px}.inventory-section>.section-head h2{max-width:none;white-space:nowrap}.review>footer>div:first-child{gap:8px}.review>footer .submit-area p{max-width:19ch}.inventory-filters button{padding-inline:8px}} +@media(max-width:800px){ + .review>header{padding:12px 14px;align-items:center}.review h1{font-size:25px}.review>header p{max-width:68vw;font-size:11px}.review .badge{display:none}.review>.preview-note{padding:6px 14px}.review>.round-summary{padding:6px 14px;max-height:76px;gap:4px 8px}.round-summary p{max-width:calc(100% - 44px);gap:4px 10px;font-size:11px}.round-summary p>span{font-size:10px}.round-summary button{font-size:11px} + .mobile-panes{display:flex;flex-shrink:0;gap:4px;padding:7px 14px;border-bottom:1px solid var(--line);background:var(--paper)}.mobile-panes button{flex:1;font-size:12px;min-height:32px;padding:5px;border-color:transparent;background:transparent}.mobile-panes button[aria-pressed=true]{background:#e8efec;color:var(--teal);box-shadow:none;border-color:#bdd0c8} + .review>.workbench{display:block;padding:12px 14px;min-height:0;overflow:hidden}.workbench[data-mobile-pane=component]>.reference,.workbench[data-mobile-pane=comp]>.inspector{display:none}.reference{height:100%;max-width:none}.connector{display:none}.inspector{height:100%;border:0}.map-legend{gap:6px 12px;font-size:10px}.map-caption{font-size:9px}.reference .section-head button{min-height:30px;padding:4px 8px}.reference .section-head h2{font-size:14px}.inspector>.section-head{min-height:26px;margin-bottom:0;padding:9px 12px}.inspection-content{padding:10px 8px 10px 12px}.inspector>.review-form{padding:8px 8px 8px 12px}.number{width:23px;height:23px;font-size:11px}.section-head h2{font-size:14px}.review-form{padding-top:8px}.decision-title strong{font-size:12px}.decision-title p{font-size:10px}.decisions>button{min-height:36px}.review-form .feedback{font-size:12px}.review-form .feedback textarea{min-height:60px}.compare{max-width:none} + .review>.inventory-section{height:160px;padding:6px 14px}.inventory-section>.section-head{flex-direction:row;flex-wrap:nowrap;align-items:center;gap:6px;min-height:34px;margin-bottom:4px}.inventory-section h2{display:none}.inventory-filters{flex:1;width:auto;min-width:0;padding:2px}.inventory-filters button{padding:4px 6px;font-size:10px;min-height:28px}.inventory-filters b{margin-left:3px}.tray-actions #show-all{display:none}.tray-actions #toggle-tray{padding:6px;min-width:32px;min-height:32px}.tray-actions #toggle-tray span{display:none}.tray-actions svg{width:16px;height:16px}.review>.inventory-section.tray-collapsed{height:46px;padding:6px 14px}.review>.inventory-section.tray-expanded{height:160px}.inventory .item{flex-basis:130px;grid-template-rows:auto 1fr auto;padding:6px}.inventory .item-thumb{display:none}.inventory.all{display:flex;overflow-x:auto;overflow-y:hidden}.inventory .item strong{font-size:11px;min-height:22px}.inventory .state{font-size:10px}.inventory .item-number{font-size:10px;min-height:14px}.inventory-empty{padding:8px 0;font-size:12px} + .review>footer{padding:8px 14px calc(8px + env(safe-area-inset-bottom));gap:8px;flex-direction:column;align-items:stretch}.review>footer>div:first-child{gap:10px;justify-content:space-between}.review>footer #approve-rest{font-size:10px;min-height:32px;max-width:47%;padding:5px 8px}.review>footer .check{font-size:10px;max-width:48%;gap:4px}.review>footer .check input{width:14px;height:14px}.review>footer .submit-area{justify-content:space-between;gap:10px}.review>footer .submit-area p{font-size:10px;max-width:22ch}.review>footer .primary{min-height:34px;font-size:12px;padding:6px 10px} +} +`;var Ae=` +:host{display:block;color:var(--color-text,#292929);font:14px/1.45 var(--font-sans,Arial,sans-serif);--line:var(--color-border,#ddd);--paper:var(--color-panel,#fff);--muted:var(--color-muted,#666);--teal:var(--color-patina,#28625e);--warn:var(--color-warn,#8a5b30);--selection:#43897f} +*{box-sizing:border-box}h1,h2,p,figure{margin:0}button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid var(--line);border-radius:4px;background:var(--paper);color:inherit;padding:8px 12px;min-height:36px}button:hover{border-color:var(--teal);color:var(--teal)}button:disabled{cursor:default;opacity:.45}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--teal);outline-offset:3px}button[aria-pressed=true]{box-shadow:inset 0 0 0 1px var(--teal)}input[type=checkbox]{accent-color:var(--teal);width:16px;height:16px;flex-shrink:0}textarea,input:not([type=checkbox]){width:100%;background:var(--paper);color:inherit;border:1px solid #999;border-radius:4px;padding:9px 10px}textarea{resize:vertical;min-height:80px}::selection{background:#c7ddd8}a{color:var(--teal)} +.review{max-width:1600px;margin:auto;padding:24px 28px 0}header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:16px}h1{font:400 40px/1.05 var(--font-display,Arial,sans-serif);letter-spacing:-.02em}header p{margin-top:8px;font-size:15px}header p span,.medium{color:var(--muted)}.badge{border:1px solid var(--line);padding:5px 10px;font-size:12px;white-space:nowrap}.preview-note{color:var(--muted);font-size:12px;border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:24px} +.connector{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:5;overflow:visible}.connector path{fill:none;stroke:var(--selection);stroke-width:2}.workbench{align-items:start;display:grid;grid-template-columns:minmax(0,1.18fr) minmax(0,1fr);gap:40px;position:relative}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;min-height:36px}.section-head h2{font-size:17px;font-weight:500;line-height:1.2}.section-head>span,.section-head h2>span:not(.number){font-size:12px;color:var(--muted)}.section-head button{font-size:12px}.number{display:inline-flex;align-items:center;justify-content:center;width:27px;height:27px;border:1px solid var(--teal);color:var(--teal);margin-right:7px;font:12px var(--font-mono,monospace)} +.map{position:relative;background:#eaeaea;isolation:isolate}.comp{width:100%;height:100%;display:block;user-select:none}.map.marking{touch-action:none;cursor:crosshair}.map.marking .pin{pointer-events:none;opacity:.25}.pin{position:absolute;transform:translate(-50%,-50%);padding:0;min-height:25px;width:25px;height:25px;border-radius:50%;border:1px solid #fff;background:#fff;color:#292929;font:11px var(--font-mono,monospace);box-shadow:0 1px 4px #0006;z-index:2}.pin.selected{background:var(--teal);color:#fff;border-color:var(--teal);box-shadow:none;outline:none;z-index:3}.pin:focus-visible{outline:2px solid white;outline-offset:3px}.region,.draw-box{position:absolute;pointer-events:none;outline:2px solid var(--selection);z-index:1}.draw-box{background:#28625e33;z-index:4}.map-caption{font-size:12px;color:var(--muted);padding-top:12px;min-height:40px}.map-caption button{display:block;margin-top:10px}.compare{display:grid;grid-template-columns:1fr 1fr;gap:12px}.compare figure{min-width:0}.compare figcaption{height:24px;min-height:0;font-size:12px;margin-bottom:9px}.compare figcaption span{display:block;color:var(--muted);font-size:11px}.crop-stage{position:relative;overflow:hidden;background:var(--comp-background,#eee);min-width:0}.crop-image{position:absolute;max-width:none;height:auto}.asset{display:block;width:100%;height:100%;object-fit:cover}.crop-stage iframe{position:absolute;max-width:none;border:0;transform-origin:top left;pointer-events:none}.overlay-image{opacity:.5;pointer-events:none}.component-note{font-size:12px;line-height:1.5;color:var(--muted);margin:6px 0 0}.decisions{display:flex;gap:10px;padding:10px 0;background:var(--paper);position:sticky;bottom:0;z-index:6}.decisions>button{flex:1;min-height:48px;font-weight:600;font-size:15px;border-color:var(--teal)}.decision-approve{background:var(--teal);color:white}.decision-approve:hover{background:#224e4b;color:white}.decision-revise{color:var(--teal);background:var(--paper)}.decisions>.quiet{flex:0;border:0;background:transparent;font-size:12px}.decisions .approved{color:var(--teal);background:#e9f0ed;border-color:var(--teal)}.decisions .revise{color:var(--warn);background:#f7f0e6;border-color:var(--warn)}.feedback{display:block;margin-top:16px;font-size:13px}.feedback>span{float:right;color:var(--muted);font-size:12px}.feedback textarea,.feedback input{display:block;margin-top:7px}.check{display:flex;align-items:center;gap:7px;font-size:12px;line-height:1.5;margin-top:12px}.coordinates{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:16px 0}.coordinates label{font-size:12px}.coordinates input{margin-top:5px} +.inventory-section{margin-top:28px;border-top:1px solid var(--line);padding-top:16px}.inventory-section .section-head{margin-bottom:10px}.inventory{display:flex;gap:8px;overflow-x:auto;padding:3px 3px 14px;scrollbar-color:#a6bcb8 #eee;scrollbar-width:thin}.item{position:relative;flex:0 0 134px;display:grid;grid-template-columns:20px 1fr;column-gap:8px;row-gap:3px;padding:10px;text-align:left;background:transparent}.item strong{grid-column:2;font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item.active{background:var(--paper);border-color:var(--teal)}.thumb-crop{position:relative;display:block;overflow:hidden}.item-thumb{display:flex;align-items:center;justify-content:center;grid-column:1/-1;position:relative;overflow:hidden;width:100%;height:76px;background:var(--comp-background,#eee);margin-bottom:7px}.item-thumb img{width:100%;height:100%;object-fit:contain}.item-thumb img[style]{height:auto}.inventory.all{display:grid;grid-template-columns:repeat(auto-fill,minmax(128px,1fr));overflow:visible}.item-number{grid-row:2/4;font:12px var(--font-mono,monospace);color:var(--muted);padding-top:2px}.state{grid-column:2;font-size:11px;color:var(--muted)}.state.approve{color:var(--teal)}.state.revise{color:var(--warn)}footer{display:flex;justify-content:space-between;gap:24px;padding:20px 0 24px;border-top:1px solid var(--line);margin-top:8px}.submit-area{display:flex;align-items:center;gap:20px}.submit-area p{font-size:12px;color:var(--muted);max-width:28ch}.primary{min-height:46px;font-weight:600;background:var(--teal);border-color:var(--teal);color:white;white-space:nowrap}.primary:hover{color:white;background:#224e4b}.primary:disabled{opacity:.45}.inspector>p{margin:12px 0} +@media(min-width:1300px){.workbench{gap:56px}.review{padding-top:32px}.component-note{max-width:62ch}} +@media(max-width:800px){.connector{display:none}.review{padding:20px 16px 0}.workbench{grid-template-columns:1fr;gap:24px}.reference{max-width:640px;margin:auto;width:100%}.inspector{border-top:1px solid var(--line);padding-top:16px}.compare{max-width:640px}.inventory-section .section-head{align-items:flex-start;flex-direction:column;gap:4px}footer{flex-direction:column}.submit-area{justify-content:space-between}.badge{font-size:11px}.section-head{gap:8px}h1{font-size:34px}header{align-items:flex-start}.section-head h2{font-size:16px}} + +.inspector{height:690px;overflow-y:auto;scrollbar-gutter:stable;scrollbar-width:thin;padding:0 5px 0 1px;overflow-anchor:none} +.material{display:flex;align-items:baseline;flex-wrap:wrap;gap:5px 12px;min-height:34px;margin-bottom:6px}.material strong{font-size:14px;font-weight:600}.material span{font:11px var(--font-mono,monospace);color:var(--muted)} +.compare-toolbar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:16px}.compare-toolbar label{font-size:12px;display:flex;align-items:center;gap:8px}select{font:inherit;color:inherit;background:var(--paper);border:1px solid #999;border-radius:4px;padding:7px 9px;min-height:36px}select:focus-visible,.pan-viewport:focus-visible{outline:2px solid var(--teal);outline-offset:2px} +.overlay-control{display:flex;align-items:center;gap:8px;border-color:var(--teal);color:var(--teal);font-weight:500}.overlay-control[aria-pressed=true]{background:#e9f0ed}.overlay-control svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.3} +.pan-viewport{height:248px;overflow:auto;display:flex;background:#efefef;scrollbar-width:thin;scrollbar-color:#829b98 #eee;overscroll-behavior:contain}.crop-stage{flex-shrink:0;margin:auto}.checker{background-color:#eee;background-image:conic-gradient(#c7c7c7 25%,transparent 0 50%,#c7c7c7 0 75%,transparent 0);background-size:16px 16px} +.view-controls{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-top:12px;min-height:36px}.view-controls>div{display:flex;background:#eee;border:1px solid var(--line);padding:2px;border-radius:4px}.view-controls button{border:0;background:transparent;font-size:12px;min-height:30px;padding:5px 9px}.view-controls button[aria-pressed=true]{background:var(--paper);box-shadow:0 1px 2px #0002}.view-controls label{font-size:11px;display:flex;gap:6px;align-items:center}.view-controls select{font-size:11px;min-height:32px;padding:5px}.view-controls>span{font-size:12px;color:var(--muted)}.scale-note{font-size:11px;color:var(--muted);margin:10px 0 0;min-height:18px}.scale-note a{white-space:nowrap} +.component-details{height:78px;overflow:auto;margin-top:12px;padding-right:4px;scrollbar-width:thin}.layering{font-size:12px;line-height:1.5}.component-details .component-note{margin-top:6px} +#approve-rest{min-height:44px;font-weight:600;border-color:var(--teal);color:var(--teal)} +@media(max-width:800px){.inspector{height:720px}.pan-viewport{height:248px}.component-details{height:92px}.view-controls label{font-size:11px}.review header{align-items:flex-start}} + +.round-summary{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 20px;padding:14px 0;margin-bottom:22px;border-block:1px solid var(--line)}.round-summary p{display:flex;flex-wrap:wrap;gap:5px 18px;font-size:13px}.round-summary p>span{color:var(--muted)}.round-summary details{flex-basis:100%;font-size:12px}.round-summary details p{font-size:12px;margin-top:8px;max-width:80ch} +.repair-context{margin:0 0 16px;font-size:12px}.previous-feedback{margin:0;padding:12px 14px;background:#f0f0ed;border-radius:4px;overflow-wrap:anywhere}.previous-feedback h3{margin:0;font-size:13px;font-weight:600;display:flex;align-items:baseline;justify-content:space-between;gap:12px}.previous-feedback h3 span{font-size:11px;color:var(--muted);font-weight:400}.previous-feedback .previous-verdict{color:var(--warn);font-size:11px;margin-top:6px}.previous-feedback blockquote{margin:4px 0 0;white-space:pre-wrap;font-size:13px;line-height:1.5}.previous-feedback>p:last-child:not(.previous-verdict){margin-top:8px}.kept-approval{color:var(--teal);font-size:12px} +.preview-round{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 14px;font-size:12px}.preview-round>span{font-weight:500}.round-switch{display:flex;gap:2px;border:1px solid var(--line);border-radius:4px;padding:2px;background:#eee}.round-switch button{min-height:30px;font-size:11px;padding:5px 8px;border:0;background:transparent}.round-switch button[aria-pressed=true]{background:var(--paper);box-shadow:0 1px 2px #0002} +.changed-files{margin-top:10px;color:var(--muted)}summary{cursor:pointer;padding:5px 0;min-height:30px}summary:focus-visible{outline:2px solid var(--teal);outline-offset:2px}.changed-files ul{padding-left:18px;margin:6px 0;overflow-wrap:anywhere;font:11px/1.6 var(--font-mono,monospace)}.description-diff{margin:6px 0 12px}.description-diff dt{font-weight:600;font-size:11px;margin-top:10px}.description-diff dd{margin:4px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--color-text,#292929)}.previous-notice{font-size:12px;color:var(--muted);margin-top:8px}.inspector .previous-notice{display:none} +.decisions{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:8px 10px;padding-top:12px}.decision-title{grid-column:1/-1}.decision-title strong{font-size:14px;font-weight:600;display:flex;justify-content:space-between;gap:12px}.decision-title strong span{font-size:11px;color:var(--muted);font-weight:400}.decision-title p{font-size:12px;font-weight:400;color:var(--muted);margin-top:4px}.decisions>.quiet{align-self:center;padding-inline:4px}.decisions:not(:has(.quiet)){grid-template-columns:1fr 1fr} +.inspector{display:flex;flex-direction:column;overflow:hidden;padding:0}.inspection-content{flex:1;min-height:0;overflow:auto;scrollbar-width:thin;scrollbar-gutter:stable;overscroll-behavior:contain;padding:0 5px 10px 1px}.review-form{flex-shrink:0;padding:12px 5px 0 1px;border-top:1px solid var(--line);background:var(--paper)}.review-form .decisions{position:static;padding:0 0 8px;background:transparent}.review-form .feedback{margin-top:8px}.review-form .feedback textarea{min-height:72px;max-height:120px}.review-form .check{margin:8px 0;font-size:11px} +.inspection-content:focus-visible{outline:2px solid var(--teal);outline-offset:-2px} +.inspector>.section-head{flex-shrink:0;padding:0 5px 0 1px} +.pin{display:flex;align-items:center;justify-content:center;gap:3px;font-size:12px;font-weight:600;width:30px;height:30px;min-height:30px}.pin svg,.map-legend svg,.item-number svg{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}.pin.pending,.pin.pending.selected{background:#f4cd68;border-color:#f4cd68;color:#3c300f}.pin.approved,.pin.approved.selected{width:38px;min-height:24px;height:24px;background:#edf3ef;border-color:#edf3ef;color:#326258;border-radius:4px}.pin.approved:not(.selected){opacity:.55;box-shadow:none}.pin.approved:hover,.pin.approved:focus-visible{opacity:1}.pin.feedback,.pin.feedback.selected{width:38px;background:#964b2f;border-color:#fff;color:#fff;border-radius:4px}.pin.selected{outline:none;border:3px solid #fff;box-shadow:none;z-index:3}.pin:focus-visible{outline:2px solid #fff;outline-offset:3px} +.map-legend{display:flex;flex-wrap:wrap;gap:9px 18px;margin-top:14px;font-size:11px;color:var(--muted)}.map-legend>span{display:flex;align-items:center;gap:6px}.map-legend i{display:inline-flex;align-items:center;justify-content:center;min-width:21px;height:21px;font-style:normal}.legend-pending{background:#f4cd68;color:#3c300f;border-radius:50%}.legend-feedback{background:#964b2f;color:#fff;border-radius:3px}.legend-approved{background:#edf3ef;color:#326258;border-radius:3px}.map-caption{font-size:11px} +.inventory-section .section-head{flex-wrap:wrap}.inventory-filters{display:flex;gap:3px;padding:3px;background:#eee;border-radius:5px}.inventory-filters button{border:0;background:transparent;min-height:34px;padding:7px 10px;font-size:12px;white-space:nowrap}.inventory-filters button[aria-pressed=true]{background:var(--paper);color:var(--teal);box-shadow:0 1px 2px #0002}.inventory-filters b{margin-left:5px;font-weight:600;font-variant-numeric:tabular-nums}.item.pending{border-color:#b18a2f;background:#fffbef}.item.feedback{border-color:#964b2f;background:#fcf1eb}.item.approved{background:#f1f4f2;border-color:#ccd8d1}.item.approved .item-thumb{opacity:.65}.item.active{outline:2px solid var(--teal);outline-offset:0;box-shadow:none}.item-number{display:flex;align-items:center;gap:3px;grid-column:1/-1;grid-row:auto;min-height:20px;font-weight:600}.item strong{grid-column:1/-1;font-size:13px;white-space:normal;min-height:36px;line-height:1.35}.state{grid-column:1/-1;font-size:12px;font-weight:600;padding-top:6px;border-top:1px solid #0002}.state.pending{color:#725819}.state.feedback{color:#914429}.state.approved{color:#326258}.inventory-empty{padding:20px 0;font-size:13px;color:var(--muted)} +@media(max-width:800px){.inventory-section .section-head{gap:10px}.inventory-filters{width:100%}.inventory-filters button{padding-inline:7px;font-size:11px;flex:1}.inventory-filters b{margin-left:3px}} +@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}} +.item-medium{margin-left:auto;font-weight:400;display:flex;align-items:center;gap:4px;font-size:10px;color:var(--muted);min-height:16px}.item-medium .utility-icon{width:14px;height:14px;flex-shrink:0}.material>.utility-icon{width:18px;height:18px;align-self:center;color:var(--teal)} +`+Re;var ai={code:'',image:'',expand:'',compact:'',hideTray:'',showTray:'',next:'',mark:'',close:'',undo:'',external:'',zoom:''};function E(n){return``}var x=(n)=>n.replace(/[&<>"']/g,(i)=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[i]),U=(n)=>`${n*100}%`,X=(n)=>{let i=new URL(n,location.href);if(!["http:","https:"].includes(i.protocol))throw Error("Unsupported preview URL");return x(i.href)};function Oe(n,i,a){let o=n.attachShadow({mode:"open"}),r=structuredClone(a.initialDraft??Ye(i)),g=()=>[...i.components].sort((m,k)=>I(m,r,a.history).priority-I(k,r,a.history).priority),l=g().find((m)=>I(m,r,a.history).kind!=="approved")?.id??i.components[0]?.id,h=a.completed?"all":"attention",H=!1,B=!1,ie=a.completed??!1,xe="",Y=!1,$=!1,T=!0,z="comp",be=z,G="fit",S="checker",R=!1,A="isolated",de,me="fit",V=null,J=null,C=null,le='',oe='',we=(m)=>`left:${U(m.x)};top:${U(m.y)};width:${U(m.w)};height:${U(m.h)}`;function ue(m){let k=i.components.find((K)=>K.id===l);if(!k)return;if(r.decisions[k.id]={revision:k.revision,action:m,feedback:r.decisions[k.id]?.feedback??"",split:m==="revise"&&(r.decisions[k.id]?.split??!1)},d(),m==="revise"){let K=o.querySelector("#feedback");K?.focus({preventScroll:!0});let F=o.querySelector(".review-form");if(F&&K){let D=K.getBoundingClientRect().bottom-F.getBoundingClientRect().bottom;if(D>0)F.scrollTop+=D+8}let P=o.querySelector(".inspection-content"),ne=o.querySelector(".compare");if(P&&ne)P.scrollTop+=ne.getBoundingClientRect().top-P.getBoundingClientRect().top}}function ve(m){let k=`missing-${crypto.randomUUID()}`;r.missing.push({id:k,name:"Missing component",feedback:"",box:m}),r.inventoryConfirmed=!1,l=k,z="component",H=!1,V=null,J=null,d(),o.querySelector("#missing-name")?.focus()}function d(){C?.disconnect();let m=o.activeElement,k=m?.id,K=m?.dataset.select,F=window.scrollX,P=window.scrollY,ne=o.querySelector(".inventory")?.scrollLeft??0,D=de===l,Ie=z==="component"&&(!D||be!==z);be=z;let Be=D?o.querySelector(".inspection-content")?.scrollTop??0:0,Se=D&&(o.querySelector(".changed-files")?.open??!1),ye=o.querySelector(".pan-viewport"),ze=de===l&&me===G,Ce=ze?ye?.scrollLeft??0:0,Fe=ze?ye?.scrollTop??0:0;de=l,me=G;let f=i.components.find((e)=>e.id===l),b=r.missing.find((e)=>e.id===l),ke=f?.box??b?.box,je=f?i.components.indexOf(f)+1:i.components.length+r.missing.findIndex((e)=>e.id===l)+1,Me=f?r.decisions[f.id]:void 0,L=Me?.revision===f?.revision?Me:void 0,u=pe(i,r),w=a.history,j=f?te(f.id,w):void 0,Z=w?.packet.components.find((e)=>e.id===f?.id),N=R&&!!Z,p=N?Z:f,M=N?w.packet:i,He=Object.values(w?.changes??{}),Te=He.filter((e)=>e.kind==="changed").length,qe=He.filter((e)=>e.kind==="added").length,se=i.components.filter((e)=>I(e,r,w).kind==="approved"&&te(e.id,w).carried).length,ee=(e)=>I(e,r,w),Pe=i.components.length-u.approved+r.missing.length,Ee=(h==="attention"?g():i.components).filter((e)=>h==="all"||ee(e).kind==="approved"===(h==="approved")),ei=[u.revisions+r.missing.length?`${u.revisions+r.missing.length} feedback ready`:"",se?`${se} ${se===1?"approval":"approvals"} kept`:"",Te?`${Te} changed`:"",qe?`${qe} added`:"",w?.removed.length?`${w.removed.length} removed`:""].filter(Boolean).join(" · "),ii=xe||(ie?a.preview?"Preview submitted. No run changed.":"Review submitted.":u.hasFeedback?"Ready to send for corrections.":u.pending?`${u.pending} left to review`:!r.inventoryConfirmed?"Confirm the map is complete.":"Ready to continue."),Q=p?ae(p):null,Ve=p?.preview.kind==="image"&&!Q?.code,W=!!(p?.context&&A==="context"),Je=p&&(W?p.context?.kind!=="image":p.preview.kind==="page"),Ne=W&&p?.context?p.context.url:p?.preview.url,oi=Q?.code?`${Q.label} · ${Q.captured?"captured from code":"live preview"}`:p?.material?`${p.material.alpha==="transparent"?"Transparent":p.material.alpha==="opaque"?"Opaque":"Transparency unverified"} ${p.material.format}`:"Raster · transparency unverified";if(o.innerHTML=`
+

Review the components.

${x(i.title)} · Round ${i.round}

${a.preview?'Interactive preview':""}
+ ${a.preview?'

Historical hotel artwork for testing this interface. Decisions stay in this preview; no run is changed.

':""} + ${w?`

${u.pending} ${u.pending===1?"component":"components"} to review${ei}

${u.pending?``:""}${w.removed.length?`
Removed from the map

${w.removed.map((e)=>x(e.name)).join(" · ")}. Confirm these omissions are intentional before accepting the map.

`:""}
`:""} +
+
+
+

Approved comp

+
+ Approved composition for ${x(i.title)} + ${ke?`
`:""} + ${i.components.map((e,t)=>{let s=ee(e);return``}).join("")} + ${r.missing.map((e,t)=>``).join("")} + + +
+
# To review${oe} Feedback ready${le} Approved
+ ${H?'
Draw around the missing piece.
':""} +
+
+

${je} ${x(f?.name??b?.name??"Component")}

+ ${f?`
${E(Q.code?"code":"image")}${x(oi)}${p?.material?`${p.material.width} × ${p.material.height} px`:""}${p?.preview.kind==="image"?`${E("external")}`:""}
+ ${w?`
+ ${j?.prior?.action==="revise"?`

Previous feedback Round ${j.feedbackRound}

Needs work

${x(j.prior.feedback||"No written feedback was supplied.")}
${j.prior.split?"

Requested: split into separately reviewable components.

":""}
`:j?.carried?'

Unchanged · approval kept

':""} + ${j?.change?.kind==="changed"?`
${j.change.files.length?`${j.change.files.length} changed ${j.change.files.length===1?"file":"files"}`:j.change.reasons.includes("region")?"Region changed":Z?.note!==f.note?"Description changed · files unchanged":"Component definition changed · files unchanged"}${j.change.files.length?`
    ${j.change.files.map((e)=>`
  • ${x(e)}
  • `).join("")}
`:""}${Z&&Z.note!==f.note?`
Previous description
${x(Z.note)}
Current description
${x(f.note)}
`:""}
`:""} +
`:""} + ${Z?`
`:""} +
+
+
${N?`Comp · Round ${w.packet.round}`:"In the comp"}
Reference region for ${x(p.name)}
+
${N?`Previous · Round ${w.packet.round}`:w?`Current · Round ${i.round}`:W?"In the page":Q.caption}
${!Je?`Produced ${x(p.name)}`:``}${Y?`Reference overlay`:""}
+
+ ${Ve?`
${p.context?`
`:""}
`:""} +

${x(p?.context?.layering??"Layer placement not recorded.")}

+

${x(p.note)}

+
${N?'

Viewing the previous round. Return to Current to make a decision.

':""}
Your review Round ${i.round}${N?"

Return to Current to review this round.

":""}
${L?``:""}
+ ${L?.action==="revise"?``:""} +
`:b?`

This piece will be added to the unresolved inventory.

${["x","y","w","h"].map((e)=>``).join("")}
`:"

No components supplied.

"} +
+ +

Components

+
${Ee.map((e)=>{let t=i.components.indexOf(e),s=ee(e);return``}).join("")}${(h==="approved"?[]:r.missing).map((e,t)=>``).join("")}${!Ee.length&&(h==="approved"||!r.missing.length)?`

${h==="attention"?"Every component is approved. Confirm the map is complete, then continue.":"No components approved yet."}

`:""}
+

${x(ii)}

+ `,N)o.querySelectorAll(".decisions button,#feedback,#split,#approve-rest,#submit,#inventory-confirm").forEach((e)=>e.disabled=!0);if(o.querySelector(".inspection-content").scrollTop=Be,ie||B)o.querySelectorAll(".decisions button,#approve-rest,#mark,#inventory-confirm,#missing-name,#missing-feedback,#feedback,#split,#remove-missing,[data-coordinate]").forEach((e)=>e.disabled=!0);if(o.querySelector(".inventory").scrollLeft=ne,!D&&T)Array.from(o.querySelectorAll(".inventory [data-select]")).find((e)=>e.dataset.select===l)?.scrollIntoView({block:"nearest",inline:"nearest"});if(k)o.getElementById(k)?.focus({preventScroll:!0});else if(K)Array.from(o.querySelectorAll(".item[data-select]")).find((e)=>e.dataset.select===K)?.focus({preventScroll:!0});let c=(e,t)=>o.querySelector(`#${e}`)?.addEventListener("click",t);o.querySelectorAll("[data-select]").forEach((e)=>e.onclick=()=>{if(H)return;l=e.dataset.select,z="component",Y=!1,G="fit",A="isolated",R=!1,d()}),c("previous-round",()=>{R=!0,d()}),c("current-round",()=>{R=!1,d()}),c("review-changes",()=>{let e=g().filter((y)=>ee(y).kind==="pending"),t=e.findIndex((y)=>y.id===l),s=e[(t+1)%e.length];if(s)l=s.id,z="component",h="attention",R=!1,G="fit",Y=!1,A="isolated",d()}),o.querySelectorAll("[data-filter]").forEach((e)=>e.onclick=()=>{h=e.dataset.filter;let t=g().filter((y)=>h==="all"||ee(y).kind==="approved"===(h==="approved"));if(!(h!=="approved"&&r.missing.some((y)=>y.id===l))&&!t.some((y)=>y.id===l)&&t.length)l=t[0].id,z="component",R=!1,G="fit",Y=!1,A="isolated";d()}),c("approve",()=>ue("approve")),c("revise",()=>ue("revise")),c("clear",()=>{if(f)delete r.decisions[f.id];d()}),c("overlay",()=>{Y=!Y,d()}),c("isolated",()=>{A="isolated",d()}),c("context",()=>{A="context",d()}),o.querySelector("#zoom")?.addEventListener("change",(e)=>{let t=e.target.value;G=t==="fit"?"fit":Number(t),d()}),c("background-checker",()=>{S="checker",d()}),c("background-page",()=>{S="page",d()}),c("show-all",()=>{$=!$,T=!0,d()}),c("toggle-tray",()=>{T=!T,d()}),c("show-comp",()=>{z="comp",d()}),c("show-component",()=>{z="component",d()}),c("mark",()=>{H=!H,d()}),c("add-box",()=>ve({x:0.35,y:0.35,w:0.2,h:0.2})),c("remove-missing",()=>{r.missing=r.missing.filter((e)=>e.id!==l),l=i.components[0]?.id,d()}),c("approve-rest",()=>{r=De(i,r),d()}),o.querySelector("#inventory-confirm")?.addEventListener("change",(e)=>{r.inventoryConfirmed=e.target.checked,d()}),o.querySelector("#feedback")?.addEventListener("input",(e)=>{if(f)r.decisions[f.id].feedback=e.target.value}),o.querySelector("#split")?.addEventListener("change",(e)=>{if(f)r.decisions[f.id].split=e.target.checked}),o.querySelector("#missing-name")?.addEventListener("input",(e)=>{if(b)b.name=e.target.value;let t=o.querySelector("#submit");if(t)t.disabled=!pe(i,r).canSubmit}),o.querySelector("#missing-feedback")?.addEventListener("input",(e)=>{if(b)b.feedback=e.target.value}),o.querySelectorAll("[data-coordinate]").forEach((e)=>e.addEventListener("change",()=>{if(!b)return;let t=e.dataset.coordinate,s=Number(e.value)/100;if(Number.isFinite(s))b.box[t]=Math.max(t==="w"||t==="h"?0.001:0,Math.min(1,s));b.box.w=Math.min(b.box.w,1-b.box.x),b.box.h=Math.min(b.box.h,1-b.box.y),d()})),c("submit",async()=>{B=!0,xe="",d();try{await a.onSubmit(Ze(i,r)),ie=!0}catch(e){xe=e instanceof Error?e.message:"Could not save. Try again."}finally{B=!1,d()}});let _=o.querySelector(".map");function $e(e){let t=_.getBoundingClientRect();return{x:Math.max(0,Math.min(1,(e.clientX-t.left)/t.width)),y:Math.max(0,Math.min(1,(e.clientY-t.top)/t.height))}}_.addEventListener("pointerdown",(e)=>{if(!H)return;V=$e(e),_.setPointerCapture(e.pointerId),e.preventDefault()}),_.addEventListener("pointermove",(e)=>{if(!V)return;let t=$e(e);J={x:Math.min(V.x,t.x),y:Math.min(V.y,t.y),w:Math.abs(t.x-V.x),h:Math.abs(t.y-V.y)};let s=o.querySelector(".draw-box");s.hidden=!1,s.style.cssText=we(J)}),_.addEventListener("pointerup",()=>{if(J&&J.w>0.01&&J.h>0.01)ve(J);else V=null,J=null}),_.addEventListener("pointercancel",()=>{V=null,J=null,d()});let Ge=o.querySelector(".output"),re=o.querySelector("iframe"),Ke=o.querySelector(".workbench"),O=o.querySelector(".inspection-content"),he=o.querySelector(".inspector");function ge(){if(!O||!he)return;he.dataset.scrollAbove=String(O.scrollTop>1),he.dataset.scrollBelow=String(O.scrollHeight-O.clientHeight-O.scrollTop>1)}O?.addEventListener("scroll",ge,{passive:!0});function Le(){let e=o.querySelector(".map-space");if(e&&e.clientWidth&&e.clientHeight){let v=ce(i.comp.width,i.comp.height,Math.max(1,e.clientWidth-32),Math.max(1,e.clientHeight-32),"fit");_.style.width=`${v.width}px`,_.style.height=`${v.height}px`}let t=o.querySelector(".inspection-content"),s=Array.from(o.querySelectorAll(".pan-viewport"));if(t?.clientHeight)s.forEach((v)=>v.style.height=`${Math.min(248,Math.max(100,t.clientHeight-40))}px`);if(p&&s.length){let v=ce(p.box.w*M.comp.width,p.box.h*M.comp.height,Math.min(...s.map((q)=>q.clientWidth)),Math.min(...s.map((q)=>q.clientHeight)),G);o.querySelectorAll(".crop-stage").forEach((q)=>{q.style.width=`${v.width}px`,q.style.height=`${v.height}px`})}if(Ge&&re&&p){let v=Ge.clientWidth/(p.box.w*M.comp.width);re.style.transform=`scale(${v})`,re.style.left=`${-p.box.x*M.comp.width*v}px`,re.style.top=`${-p.box.y*M.comp.height*v}px`}let y=Ke.getBoundingClientRect(),We=o.querySelector(".region"),_e=o.querySelector(".number"),Ue=o.querySelector(".connector path");if(We&&_e&&Ue){let v=We.getBoundingClientRect(),q=_e.getBoundingClientRect(),ni=v.right-y.left,ri=v.top+v.height/2-y.top,Xe=q.left-y.left-8,ti=q.top+q.height/2-y.top;Ue.setAttribute("d",`M ${ni} ${ri} H ${Xe-14} V ${ti} H ${Xe}`)}}C=new ResizeObserver(()=>{Le(),ge()}),C.observe(Ke);let Qe=o.querySelector(".inspection-content");if(Qe)C.observe(Qe);Le();let fe=Array.from(o.querySelectorAll(".pan-viewport"));if(fe.forEach((e)=>{e.scrollLeft=Ce,e.scrollTop=Fe}),fe.forEach((e)=>e.addEventListener("scroll",()=>{for(let t of fe)if(t!==e){if(t.scrollLeft!==e.scrollLeft)t.scrollLeft=e.scrollLeft;if(t.scrollTop!==e.scrollTop)t.scrollTop=e.scrollTop}})),Ie&&window.matchMedia("(max-width:800px)").matches){let e=o.querySelector(".inspection-content"),t=o.querySelector(".compare");if(e&&t)e.scrollTop+=t.getBoundingClientRect().top-e.getBoundingClientRect().top}ge(),window.scrollTo(F,P)}return d(),{destroy(){C?.disconnect(),o.replaceChildren()},getDraft(){return structuredClone(r)}}}async function xi(){let n=document.getElementById("review"),i=await fetch("/packet",{cache:"no-store"});if(!i.ok)throw Error("The review packet could not be loaded. Reload to retry.");let a=await i.json();if(a.sourceStatus){let o=document.createElement("p");o.textContent=a.sourceStatus,n.before(o)}Oe(n,a.packet,{initialDraft:a.draft,history:a.history,completed:!!a.receipt,onSubmit:async(o)=>{let r=await fetch("/decision",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)}),g=await r.json();if(!r.ok)throw Error(g.error??"The review could not be saved. Try again.")}})}xi().catch((n)=>{let i=document.createElement("p");i.textContent=n instanceof Error?n.message:String(n),document.getElementById("review")?.replaceChildren(i)});})(); diff --git a/crates/context/src/component_review/capture.rs b/crates/context/src/component_review/capture.rs new file mode 100644 index 000000000..3a29934e9 --- /dev/null +++ b/crates/context/src/component_review/capture.rs @@ -0,0 +1,16 @@ +//! Browser adapters return fresh in-process evidence; producer JSON is never capture authority. +use serde_json::Value; +use std::collections::BTreeMap; +pub struct CapturedPreviews { + pub files: BTreeMap>, + pub evidence: Value, +} +pub trait ComponentCapturer { + /// Replace preview URLs in a frozen packet with native captures. Only pinned + /// input bytes may be rendered; output files use reserved capture paths. + fn capture( + &mut self, + packet: &mut Value, + inputs: &BTreeMap>, + ) -> Result; +} diff --git a/crates/context/src/component_review/history.rs b/crates/context/src/component_review/history.rs new file mode 100644 index 000000000..1c8c7170c --- /dev/null +++ b/crates/context/src/component_review/history.rs @@ -0,0 +1,109 @@ +//! Repair history is derived from stored packets, never supplied by the producer. +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; + +fn inputs(state: &Value, component: &Value) -> BTreeMap { + let mut paths = BTreeSet::new(); + if let Some(deps) = component["dependencies"].as_array() { + for p in deps.iter().filter_map(Value::as_str) { + paths.insert(p.to_string()); + } + } + if let Some(capture) = state["capture"]["components"] + .as_array() + .and_then(|all| all.iter().find(|c| c["id"] == component["id"])) + { + for view in capture["views"] + .as_object() + .into_iter() + .flat_map(|m| m.values()) + { + for key in ["path", "entry"] { + if let Some(path) = view[key].as_str() { + paths.insert(path.to_string()); + } + } + if let Some(deps) = view["observedDependencies"].as_object() { + paths.extend(deps.keys().cloned()); + } + } + } + let prefix = format!( + "/files/{}/", + state["packet"]["revision"].as_str().unwrap_or("") + ); + for view in [ + &state["packet"]["comp"], + &component["preview"], + &component["context"], + &component["thumbnail"], + ] { + if let Some(p) = view["url"] + .as_str() + .and_then(|url| url.strip_prefix(&prefix)) + { + if !p.starts_with("_review_captures/") { + paths.insert(p.to_string()); + } + } + } + paths + .into_iter() + .map(|p| { + let hash = state["files"][&p].clone(); + (p, hash) + }) + .collect() +} + +pub fn between(previous: &Value, current: &Value) -> Value { + let old = previous["packet"]["components"].as_array().unwrap(); + let new = current["packet"]["components"].as_array().unwrap(); + let mut changes = serde_json::Map::new(); + let mut feedback = serde_json::Map::new(); + for component in new { + let id = component["id"].as_str().unwrap(); + let mut change = if let Some(prior) = old.iter().find(|c| c["id"] == id) { + let before = inputs(previous, prior); + let after = inputs(current, component); + let files: BTreeSet<_> = before + .keys() + .chain(after.keys()) + .filter(|p| before.get(*p) != after.get(*p)) + .cloned() + .collect(); + let unchanged = prior["revision"] == component["revision"]; + let mut reasons = Vec::new(); + if !files.is_empty() { + reasons.push("files"); + } + if prior["box"] != component["box"] { + reasons.push("region"); + } + if !unchanged && reasons.is_empty() { + reasons.push("definition"); + } + json!({"kind":if unchanged{"unchanged"}else{"changed"},"files":files,"reasons":reasons}) + } else { + json!({"kind":"added","files":[],"reasons":[]}) + }; + change["carried"] = json!(current["draft"]["decisions"][id]["action"] == "approve"); + let decision = &previous["draft"]["decisions"][id]; + if decision["action"] == "revise" && !previous["receipt"].is_null() { + feedback.insert( + id.into(), + json!({"round":previous["packet"]["round"],"decision":decision}), + ); + } else if decision["action"] != "approve" && previous["history"]["feedback"][id].is_object() + { + feedback.insert(id.into(), previous["history"]["feedback"][id].clone()); + } + changes.insert(id.into(), change); + } + let removed: Vec<_> = old + .iter() + .filter(|c| !new.iter().any(|n| n["id"] == c["id"])) + .map(|c| json!({"id":c["id"],"name":c["name"]})) + .collect(); + json!({"packet":previous["packet"],"draft":previous["draft"],"submitted":!previous["receipt"].is_null(),"changes":changes,"feedback":feedback,"removed":removed}) +} diff --git a/crates/context/src/component_review/manifest.rs b/crates/context/src/component_review/manifest.rs new file mode 100644 index 000000000..d7b3a234d --- /dev/null +++ b/crates/context/src/component_review/manifest.rs @@ -0,0 +1,197 @@ +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::{Component, Path, PathBuf}, +}; + +pub fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +pub fn string<'a>(v: &'a Value, key: &str) -> Result<&'a str, String> { + v.get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| format!("missing {key}")) +} +pub fn relative(value: &str) -> Result { + let p = Path::new(value); + if p.is_absolute() + || value.contains(['\\', '?', '#', '%', ':']) + || !p.components().all(|c| matches!(c, Component::Normal(_))) + { + return Err(format!("expected a plain project-relative path: {value}")); + } + Ok(p.to_path_buf()) +} +pub fn valid_box(v: &Value) -> bool { + let a: Option> = ["x", "y", "w", "h"] + .iter() + .map(|k| v.get(k).and_then(Value::as_f64)) + .collect(); + a.map(|a| { + a.iter().all(|n| n.is_finite()) + && a[0] >= 0. + && a[1] >= 0. + && a[2] > 0. + && a[3] > 0. + && a[0] + a[2] <= 1.00001 + && a[1] + a[3] <= 1.00001 + }) + .unwrap_or(false) +} +fn pin( + project: &Path, + path: &str, + files: &mut BTreeMap>, +) -> Result { + let rel = relative(path)?; + let full = project + .join(rel) + .canonicalize() + .map_err(|e| format!("{path}: {e}"))?; + if !full.starts_with(project) || !full.is_file() { + return Err(format!("file escapes project: {path}")); + } + let size = std::fs::metadata(&full).map_err(|e| e.to_string())?.len(); + if size > 32 * 1024 * 1024 { + return Err(format!("file exceeds 32 MiB: {path}")); + } + let bytes = std::fs::read(full).map_err(|e| e.to_string())?; + if files.values().map(Vec::len).sum::() + bytes.len() > 256 * 1024 * 1024 { + return Err("review exceeds 256 MiB".into()); + } + let hash = digest(&bytes); + files.insert(path.into(), bytes); + Ok(hash) +} +fn view( + v: &mut Value, + project: &Path, + files: &mut BTreeMap>, + used: &mut BTreeMap, +) -> Result<(), String> { + let p = string(v, "path")?.to_string(); + let hash = pin(project, &p, files)?; + used.insert(p.clone(), hash); + v.as_object_mut() + .ok_or("preview must be an object")? + .remove("path"); + v["url"] = json!(format!("/files/{p}")); + Ok(()) +} +/// Producer declares the dependency closure. Capturer verification is a separate gate; +/// pinning a supplied screenshot is not proof that it was captured from these sources. +pub fn freeze(project: &Path, input: &Value) -> Result<(Value, BTreeMap>), String> { + let canonical = project.canonicalize().map_err(|e| e.to_string())?; + let project = canonical.as_path(); + if input["schemaVersion"] != 1 { + return Err("manifest schemaVersion must be 1".into()); + } + let mut packet = input.clone(); + for key in ["capture", "captureVerified"] { + packet + .as_object_mut() + .ok_or("manifest must be an object")? + .remove(key); + } + string(input, "id")?; + string(input, "title")?; + if input["id"].as_str().unwrap().len() > 160 { + return Err("id too long".into()); + } + for k in ["width", "height"] { + if !input["comp"][k] + .as_u64() + .is_some_and(|n| n > 0 && n <= 16384) + { + return Err(format!("invalid comp {k}")); + } + } + let mut files = BTreeMap::new(); + let mut comp_files = BTreeMap::new(); + view(&mut packet["comp"], project, &mut files, &mut comp_files)?; + let mut ids = BTreeSet::new(); + let components = packet["components"] + .as_array_mut() + .ok_or("components must be an array")?; + if components.is_empty() || components.len() > 200 { + return Err("supply 1–200 components".into()); + } + for c in components { + for key in ["capture", "captureVerified"] { + c.as_object_mut() + .ok_or("component must be an object")? + .remove(key); + } + for key in ["preview", "context", "thumbnail"] { + if let Some(view) = c.get_mut(key).and_then(Value::as_object_mut) { + view.remove("sourceKind"); + view.remove("capture"); + } + } + let id = string(c, "id")?.to_string(); + if !ids.insert(id) || !valid_box(&c["box"]) { + return Err("duplicate component or invalid box".into()); + } + for k in ["name", "medium", "note"] { + if !c[k].is_string() { + return Err(format!("component needs {k}")); + } + } + if !matches!(c["preview"]["kind"].as_str(), Some("image" | "page")) { + return Err("preview kind must be image or page".into()); + } + let deps = c["dependencies"] + .as_array() + .ok_or("each component must declare its dependencies array")? + .clone(); + let mut used = comp_files.clone(); + for dep in deps { + let p = dep.as_str().ok_or("dependency must be a path")?; + used.insert(p.into(), pin(project, p, &mut files)?); + } + let preview_path = string(&c["preview"], "path")?.to_string(); + view(&mut c["preview"], project, &mut files, &mut used)?; + c.as_object_mut().unwrap().remove("material"); + if c["preview"]["kind"] == "image" { + let bytes = &files[&preview_path]; + if impeccable_comp::png_io::is_png(bytes) { + if bytes.len() < 24 { + return Err("truncated PNG".into()); + } + let width = u32::from_be_bytes(bytes[16..20].try_into().unwrap()); + let height = u32::from_be_bytes(bytes[20..24].try_into().unwrap()); + if u64::from(width) * u64::from(height) > 32_000_000 { + return Err("PNG exceeds 32 megapixels".into()); + } + let image = impeccable_comp::png_io::decode_png(bytes)?.image; + let transparent = image.data.chunks_exact(4).any(|p| p[3] < 255); + c["material"] = json!({"format":"PNG","width":image.width,"height":image.height,"alpha":if transparent{"transparent"}else{"opaque"}}); + } + } + for k in ["context", "thumbnail"] { + if c.get(k).is_some() { + if let Some(b) = c[k].get("box") { + if !valid_box(b) { + return Err("invalid thumbnail box".into()); + } + } + view(&mut c[k], project, &mut files, &mut used)?; + } + } + c.as_object_mut().unwrap().remove("revision"); + c["revision"] = json!(digest( + &serde_json::to_vec(&json!({"component":c,"files":used})).unwrap() + )); + } + let total: usize = files.values().map(Vec::len).sum(); + if total > 256 * 1024 * 1024 { + return Err("review exceeds 256 MiB".into()); + } + packet.as_object_mut().unwrap().remove("revision"); + packet.as_object_mut().unwrap().remove("round"); + let revision = digest(&serde_json::to_vec(&packet).unwrap()); + packet["revision"] = json!(revision); + Ok((packet, files)) +} diff --git a/crates/context/src/component_review/mod.rs b/crates/context/src/component_review/mod.rs new file mode 100644 index 000000000..a1ca5d82c --- /dev/null +++ b/crates/context/src/component_review/mod.rs @@ -0,0 +1,115 @@ +//! Component review is an explicit, opt-in runtime. It does not yet replace the build-phase gates. +pub mod capture; +mod history; +mod manifest; +mod server; +mod store; +pub mod verify; +#[cfg(test)] +mod tests; +use impeccable_common::Io; +use serde_json::json; +use std::path::PathBuf; +fn arg(args: &[String], name: &str) -> Option { + args.iter() + .position(|v| v == name) + .and_then(|i| args.get(i + 1)) + .cloned() +} +fn decode_path(s: &str) -> Result { + let mut out = Vec::new(); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + if i + 2 >= bytes.len() { + return Err("bad path escape".into()); + } + let pair = std::str::from_utf8(&bytes[i + 1..i + 3]).map_err(|e| e.to_string())?; + out.push(u8::from_str_radix(pair, 16).map_err(|e| e.to_string())?); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + let decoded = String::from_utf8(out).map_err(|e| e.to_string())?; + if decoded.chars().any(char::is_control) { + return Err("control character in path".into()); + } + Ok(decoded) +} +pub fn run(args: &[String], io: &mut Io) -> i32 { + run_with_capturer(args, io, None) +} +pub fn run_with_capturer( + args: &[String], + io: &mut Io, + mut capturer: Option<&mut dyn capture::ComponentCapturer>, +) -> i32 { + let result = (|| -> Result<(), String> { + let store = arg(args, "--store") + .map(PathBuf::from) + .or_else(|| io.home().map(|h| h.join(".impeccable/component-reviews"))) + .ok_or("no home directory; supply --store outside the project")?; + match args.first().map(String::as_str) { + Some("prepare") | Some("capture") => { + let path = arg(args, "--manifest") + .ok_or("prepare needs --manifest ")?; + let project = io.cwd.canonicalize().map_err(|e| e.to_string())?; + let renderer=if args[0]=="capture" {Some(capturer.take().ok_or("native component capturer unavailable")?)}else{None}; + let dir=store::prepare_file(&store,&project,&path,renderer)?; + let state = store::read(&dir.join("current.json"))?; + let status = state["receipt"]["visualDecision"] + .as_str() + .unwrap_or("awaiting-review"); + io.out(&format!("{}\n", json!({ + "session": dir.file_name().unwrap().to_string_lossy(), + "revision": state["packet"]["revision"], + "status": status, + "capture": state["capture"], + "round": state["packet"]["round"] + }))); + Ok(()) + } + Some("verify") => { + let path = arg(args, "--manifest").ok_or("verify needs --manifest ")?; + let receipt = verify::approved(&store, &io.cwd, &path)?; + io.out(&format!("{}\n", receipt)); + Ok(()) + } + Some("serve") | Some("status") => { + let id = arg(args, "--session").ok_or("needs --session ")?; + if id.len() != 64 || !id.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("invalid session id".into()); + } + let dir = store.join(id); + if args[0] == "serve" { + let port = arg(args, "--port") + .unwrap_or_else(|| "0".into()) + .parse::() + .map_err(|e| e.to_string())?; + server::serve(&dir, port, io) + } else { + let state = store::read(&dir.join("current.json"))?; + io.out(&format!("{}\n", json!({ + "revision": state["packet"]["revision"], + "receipt": state["receipt"], + "capture": state["capture"], + "sourceStatus": store::sources_current(&state).err(), + "service": store::read(&dir.join("service.json")).ok() + }))); + Ok(()) + } + } + _ => Err("usage: impeccable component-review prepare|capture|verify --manifest | serve --session [--port 0] | status --session [--store ]".into()) + } + })(); + match result { + Ok(()) => 0, + Err(e) => { + io.err(&format!("component-review: {e}\n")); + 1 + } + } +} diff --git a/crates/context/src/component_review/server.rs b/crates/context/src/component_review/server.rs new file mode 100644 index 000000000..17a8cd3b5 --- /dev/null +++ b/crates/context/src/component_review/server.rs @@ -0,0 +1,216 @@ +use super::{ + manifest::{digest, relative}, + store, +}; +use impeccable_common::Io; +use serde_json::{Value, json}; +use std::{io::Read, path::Path}; + +const JS: &str = include_str!("../../assets/component-review.js"); +const HTML: &str = "Component review · Impeccable
"; +const CSS: &str = "@font-face{font-family:Albert Sans;src:url(/fonts/albertsans.ttf)}@font-face{font-family:Alumni Sans;src:url(/fonts/alumnisans.ttf)}@font-face{font-family:JetBrains Mono;src:url(/fonts/jetbrainsmono.ttf)}body{margin:0;background:#fafafa;--font-sans:'Albert Sans',Arial,sans-serif;--font-display:'Alumni Sans',Arial,sans-serif;--font-mono:'JetBrains Mono',monospace}body>p{padding:24px;font:16px var(--font-sans)}"; +fn respond(req: tiny_http::Request, code: u16, body: Vec, mime: &str, csp: &str) { + let mut response = tiny_http::Response::from_data(body).with_status_code(code); + for (k, v) in [ + ("Content-Type", mime), + ("Cache-Control", "no-store"), + ("X-Content-Type-Options", "nosniff"), + ("Content-Security-Policy", csp), + ("Referrer-Policy", "no-referrer"), + ] { + response = response.with_header(tiny_http::Header::from_bytes(k, v).unwrap()); + } + if mime.starts_with("font/") { + response = response.with_header( + tiny_http::Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap(), + ); + } + let _ = req.respond(response); +} +fn header<'a>(req: &'a tiny_http::Request, name: &'static str) -> Option<&'a str> { + req.headers() + .iter() + .find(|h| h.field.equiv(name)) + .map(|h| h.value.as_str()) +} +pub fn authorized( + method: &str, + host: Option<&str>, + origin: Option<&str>, + site: Option<&str>, + port: u16, +) -> bool { + let expected = format!("127.0.0.1:{port}"); + host == Some(expected.as_str()) + && (method != "POST" + || (origin == Some(format!("http://{expected}").as_str()) + && site == Some("same-origin"))) +} +pub fn packet_state(dir: &Path, revision: Option<&str>) -> Result { + let current = store::read(&dir.join("current.json"))?; + let historical = revision.is_some_and(|rev| current["packet"]["revision"] != rev); + let state = if historical { + let rev = revision.unwrap(); + if rev.len() != 64 || !rev.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("invalid packet revision".into()); + } + let state = store::read(&dir.join(format!("revisions/{rev}.json")))?; + if state["packet"]["revision"] != rev { + return Err("packet revision mismatch".into()); + } + state + } else { + current + }; + let source_status = if historical { + if state["receipt"].is_null() { + Some("This unsubmitted review was superseded by a newer capture.".to_string()) + } else { + None + } + } else { + store::sources_current(&state).err() + }; + Ok( + json!({"packet":state["packet"],"draft":state["draft"],"history":state["history"],"receipt":state["receipt"],"sourceStatus":source_status,"historical":historical}), + ) +} +pub fn serve(dir: &Path, port: u16, io: &mut Io) -> Result<(), String> { + store::read(&dir.join("current.json"))?; + let server = tiny_http::Server::http(("127.0.0.1", port)).map_err(|e| e.to_string())?; + let actual = server + .server_addr() + .to_ip() + .ok_or("listener is not TCP")? + .port(); + let service = json!({"url":format!("http://127.0.0.1:{actual}/"),"pid":std::process::id()}); + store::write(&dir.join("service.json"), &service)?; + io.out(&format!("COMPONENT REVIEW: {}\n", service)); + let trusted_csp = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; + for mut req in server.incoming_requests() { + let method = req.method().as_str().to_string(); + let path = req.url().split('?').next().unwrap_or("").to_string(); + if !authorized( + &method, + header(&req, "Host"), + header(&req, "Origin"), + header(&req, "Sec-Fetch-Site"), + actual, + ) { + respond( + req, + 403, + b"Request origin refused".to_vec(), + "text/plain", + trusted_csp, + ); + continue; + } + let result: Result<(Vec, &str), String> = (|| { + match (method.as_str(), path.as_str()) { + ("GET", "/") => Ok((HTML.as_bytes().to_vec(), "text/html; charset=utf-8")), + ("GET", "/review.js") => { + Ok((JS.as_bytes().to_vec(), "text/javascript; charset=utf-8")) + } + ("GET", "/review.css") => Ok((CSS.as_bytes().to_vec(), "text/css")), + ("GET", "/fonts/albertsans.ttf") => Ok(( + include_bytes!("../../../../ui/component-review/fonts/albertsans.ttf").to_vec(), + "font/ttf", + )), + ("GET", "/fonts/alumnisans.ttf") => Ok(( + include_bytes!("../../../../ui/component-review/fonts/alumnisans.ttf").to_vec(), + "font/ttf", + )), + ("GET", "/fonts/jetbrainsmono.ttf") => Ok(( + include_bytes!("../../../../ui/component-review/fonts/jetbrainsmono.ttf") + .to_vec(), + "font/ttf", + )), + ("GET", "/packet") => Ok(( + serde_json::to_vec(&packet_state(dir, None)?).unwrap(), + "application/json", + )), + ("POST", "/decision") => { + if !header(&req, "Content-Type") + .is_some_and(|t| t.starts_with("application/json")) + { + return Err("expected application/json".into()); + } + let mut data = Vec::new(); + req.as_reader() + .take(512 * 1024 + 1) + .read_to_end(&mut data) + .map_err(|e| e.to_string())?; + if data.len() > 512 * 1024 { + return Err("decision exceeds 512 KiB".into()); + } + let body: Value = serde_json::from_slice(&data).map_err(|e| e.to_string())?; + Ok(( + serde_json::to_vec(&store::submit(dir, &body)?).unwrap(), + "application/json", + )) + } + _ if method == "GET" && path.starts_with("/packet/") => Ok(( + serde_json::to_vec(&packet_state(dir, Some(&path[8..]))?).unwrap(), + "application/json", + )), + _ if method == "GET" && path.starts_with("/files/") => { + let rest = &path[7..]; + let (rev, encoded) = rest.split_once('/').ok_or("invalid file route")?; + if rev.len() != 64 || !rev.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("invalid revision".into()); + } + // Only percent-encoded UTF-8/spaces are decoded; relative() rejects traversal and control URL syntax. + let name = super::decode_path(encoded)?; + relative(&name)?; + let state = store::read(&dir.join(format!("revisions/{rev}.json")))?; + let hash = state["files"][&name] + .as_str() + .ok_or("file was not pinned in this review")?; + let bytes = + std::fs::read(dir.join("blobs").join(hash)).map_err(|e| e.to_string())?; + if digest(&bytes) != hash { + return Err("review snapshot integrity failure".into()); + } + let mime = match name + .rsplit('.') + .next() + .unwrap_or("") + .to_ascii_lowercase() + .as_str() + { + "html" | "htm" => "text/html; charset=utf-8", + "css" => "text/css", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "woff2" => "font/woff2", + "woff" => "font/woff", + "ttf" => "font/ttf", + _ => "application/octet-stream", + }; + Ok((bytes, mime)) + } + _ => Err("route not found".into()), + } + })(); + let file_csp = "default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; script-src 'none'; connect-src 'none'; base-uri 'none'; form-action 'none'; sandbox; frame-ancestors 'self'"; + let csp = if path.starts_with("/files/") { + file_csp + } else { + trusted_csp + }; + match result { + Ok((body, mime)) => respond(req, 200, body, mime, csp), + Err(error) => respond( + req, + 409, + serde_json::to_vec(&json!({"error":error})).unwrap(), + "application/json", + csp, + ), + } + } + Ok(()) +} diff --git a/crates/context/src/component_review/store.rs b/crates/context/src/component_review/store.rs new file mode 100644 index 000000000..9c0eced6a --- /dev/null +++ b/crates/context/src/component_review/store.rs @@ -0,0 +1,377 @@ +use super::manifest::{digest, freeze, relative, string, valid_box}; +use serde_json::{Value, json}; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; + +pub fn read(path: &Path) -> Result { + serde_json::from_slice(&fs::read(path).map_err(|e| e.to_string())?).map_err(|e| e.to_string()) +} +pub fn write(path: &Path, value: &Value) -> Result<(), String> { + let parent = path.parent().ok_or("missing parent")?; + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let temp = path.with_extension(format!("tmp-{}", std::process::id())); + let mut f = fs::File::create(&temp).map_err(|e| e.to_string())?; + f.write_all(&serde_json::to_vec_pretty(value).unwrap()) + .map_err(|e| e.to_string())?; + f.sync_all().map_err(|e| e.to_string())?; + fs::rename(&temp, path).map_err(|e| e.to_string())?; + Ok(()) +} +pub struct Lock(PathBuf); +impl Drop for Lock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} +pub fn lock(dir: &Path) -> Result { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700)).map_err(|e| e.to_string())?; + } + let p = dir.join("review.lock"); + // One writer across prepare, HTTP submission and restart. A dead process leaves no live lock. + for _ in 0..20 { + match fs::OpenOptions::new().write(true).create_new(true).open(&p) { + Ok(mut f) => { + writeln!(f, "{}", std::process::id()).map_err(|e| e.to_string())?; + return Ok(Lock(p)); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + if let Ok(pid) = fs::read_to_string(&p) + .unwrap_or_default() + .trim() + .parse::() + { + if matches!(impeccable_common::proc::kill0(pid), Err("ESRCH")) { + let _ = fs::remove_file(&p); + continue; + } + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } + Err(e) => return Err(e.to_string()), + } + } + Err("review is busy; retry shortly".into()) +} +pub fn session_dir(store: &Path, project: &Path, id: &str) -> PathBuf { + store.join(digest(format!("{}\0{id}", project.display()).as_bytes())) +} +#[cfg(test)] +pub fn prepare(store: &Path, project: &Path, input: &Value) -> Result { + prepare_captured(store, project, input, None) +} +#[cfg(test)] +pub fn prepare_captured( + store: &Path, + project: &Path, + input: &Value, + capturer: Option<&mut dyn super::capture::ComponentCapturer>, +) -> Result { + prepare_bound(store, project, input, capturer, None) +} +pub fn prepare_file( + store: &Path, + project: &Path, + path: &str, + capturer: Option<&mut dyn super::capture::ComponentCapturer>, +) -> Result { + let canonical = project.canonicalize().map_err(|e| e.to_string())?; + let project = canonical.as_path(); + let path = relative(path)?; + let full = project + .join(&path) + .canonicalize() + .map_err(|e| e.to_string())?; + if !full.starts_with(project) { + return Err("manifest escapes project".into()); + } + let bytes = fs::read(&full).map_err(|e| e.to_string())?; + if bytes.len() > 2 * 1024 * 1024 { + return Err("manifest exceeds 2 MiB".into()); + } + let input = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + prepare_bound( + store, + project, + &input, + capturer, + Some((path.to_string_lossy().into_owned(), bytes)), + ) +} +fn prepare_bound( + store: &Path, + project: &Path, + input: &Value, + capturer: Option<&mut dyn super::capture::ComponentCapturer>, + binding: Option<(String, Vec)>, +) -> Result { + let canonical = project.canonicalize().map_err(|e| e.to_string())?; + let project = canonical.as_path(); + let (mut packet, mut files) = freeze(project, input)?; + let manifest_digest = binding + .as_ref() + .map(|(path, bytes)| json!({"path":path,"sha256":digest(bytes)})); + if let Some((path, bytes)) = binding { + files.insert(path, bytes); + } + let sources: serde_json::Map = files + .iter() + .map(|(p, b)| (p.clone(), json!(digest(b)))) + .collect(); + fs::create_dir_all(store).map_err(|e| e.to_string())?; + if store + .canonicalize() + .map_err(|e| e.to_string())? + .starts_with(project) + { + return Err("review store must be outside the builder project".into()); + } + let dir = session_dir(store, project, string(input, "id")?); + let _guard = lock(&dir)?; + let old = read(&dir.join("current.json")).ok(); + let capture = if let Some(capturer) = capturer { + let captured = capturer.capture(&mut packet, &files)?; + for (path, bytes) in captured.files { + relative(&path)?; + if !path.starts_with("_review_captures/") || files.contains_key(&path) { + return Err("invalid native capture output path".into()); + } + if bytes.len() > 32 * 1024 * 1024 + || files.values().map(Vec::len).sum::() + bytes.len() > 256 * 1024 * 1024 + { + return Err("native captures exceed review byte budget".into()); + } + files.insert(path, bytes); + } + if captured.evidence["schema"] != "native-component-previews-v1" + || captured.evidence["components"].as_array().map(Vec::len) + != packet["components"].as_array().map(Vec::len) + { + return Err("native capture did not cover every component".into()); + } + for c in packet["components"] + .as_array_mut() + .ok_or("missing captured components")? + { + let images: Vec<_> = ["preview", "context", "thumbnail"] + .iter() + .filter_map(|key| c[*key]["url"].as_str()) + .map(|url| { + let path = url + .strip_prefix("/files/") + .ok_or("capture URL must be pinned")?; + let bytes = files.get(path).ok_or("capture image missing")?; + Ok(json!({"path":path,"sha256":digest(bytes)})) + }) + .collect::>()?; + c["revision"] = json!(digest( + &serde_json::to_vec(&json!({"component":c,"images":images})).unwrap() + )); + } + packet.as_object_mut().unwrap().remove("revision"); + packet["revision"] = json!(digest(&serde_json::to_vec(&packet).unwrap())); + // Detect source edits during rendering before committing a new review round. + sources_current(&json!({"project":project,"sources":sources}))?; + captured.evidence + } else { + Value::Null + }; + let content_revision = if let Some(binding) = manifest_digest { + digest( + &serde_json::to_vec(&json!({"packet":packet["revision"],"manifest":binding})).unwrap(), + ) + } else { + string(&packet, "revision")?.to_string() + }; + sources_current(&json!({"project":project,"sources":sources}))?; + if old.as_ref().is_some_and(|o| { + o["contentRevision"] + .as_str() + .unwrap_or_else(|| o["packet"]["revision"].as_str().unwrap_or("")) + == content_revision + }) { + return Ok(dir); + } + let round = old + .as_ref() + .and_then(|o| o["packet"]["round"].as_u64()) + .unwrap_or(0) + + 1; + // Content may return to an earlier version. A review round must never do so: + // otherwise an old submission could authorize this new review accidentally. + let rev = digest( + &serde_json::to_vec(&json!({ + "content":content_revision,"round":round, + "previous":old.as_ref().map(|o| &o["packet"]["revision"]) + })) + .unwrap(), + ); + packet["revision"] = json!(rev); + packet["round"] = json!(round); + // URLs include the frozen revision, so a new round cannot silently replace old preview pixels. + let prefix = format!("/files/{rev}/"); + packet["comp"]["url"] = json!( + packet["comp"]["url"] + .as_str() + .unwrap() + .replacen("/files/", &prefix, 1) + ); + for c in packet["components"].as_array_mut().unwrap() { + for key in ["preview", "context", "thumbnail"] { + if let Some(url) = c[key]["url"].as_str() { + c[key]["url"] = json!(url.replacen("/files/", &prefix, 1)); + } + } + } + let mut decisions = serde_json::Map::new(); + let previous = old + .as_ref() + .map(|v| v["draft"].clone()) + .unwrap_or(Value::Null); + for c in packet["components"].as_array().unwrap() { + let id = string(c, "id")?; + let d = &previous["decisions"][id]; + if d["revision"] == c["revision"] && d["action"] == "approve" { + decisions.insert(id.into(), d.clone()); + } + } + let missing = previous["missing"].as_array().cloned().unwrap_or_default(); + let draft = json!({"packetRevision":rev,"decisions":decisions,"missing":missing,"inventoryConfirmed":false}); + let mut hashes = serde_json::Map::new(); + let blobs = dir.join("blobs"); + fs::create_dir_all(&blobs).map_err(|e| e.to_string())?; + for (path, bytes) in files { + let hash = digest(&bytes); + fs::write(blobs.join(&hash), bytes).map_err(|e| e.to_string())?; + hashes.insert(path, json!(hash)); + } + if let Some(old) = &old { + if let Some(old_rev) = old["packet"]["revision"].as_str() { + write(&dir.join(format!("revisions/{old_rev}.json")), old)?; + } + } + let mut state = json!({"schemaVersion":1,"contentRevision":content_revision,"project":project,"packet":packet,"files":hashes,"sources":sources,"capture":capture,"draft":draft,"receipt":null}); + state["history"] = old + .as_ref() + .map(|previous| super::history::between(previous, &state)) + .unwrap_or(Value::Null); + write(&dir.join(format!("revisions/{rev}.json")), &state)?; + write(&dir.join("current.json"), &state)?; + Ok(dir) +} +pub fn sources_current(state: &Value) -> Result<(), String> { + let project = Path::new(string(state, "project")?); + for (path, hash) in state + .get("sources") + .unwrap_or(&state["files"]) + .as_object() + .ok_or("missing pinned files")? + { + let full = project + .join(relative(path)?) + .canonicalize() + .map_err(|_| format!("review is stale: {path} disappeared"))?; + if !full.starts_with(project) + || digest(&fs::read(full).map_err(|e| e.to_string())?) != hash.as_str().unwrap_or("") + { + return Err(format!( + "review is stale: {path} changed; prepare a new round" + )); + } + } + Ok(()) +} +pub fn submit(dir: &Path, body: &Value) -> Result { + let _guard = lock(dir)?; + let mut state = read(&dir.join("current.json"))?; + if body.as_object().is_none_or(|m| { + m.keys().any(|k| { + ![ + "schemaVersion", + "requestId", + "packetRevision", + "decisions", + "missing", + "inventoryConfirmed", + ] + .contains(&k.as_str()) + }) + }) { + return Err("unexpected review fields".into()); + } + let packet = &state["packet"]; + if body["schemaVersion"] != 1 + || body["requestId"] != packet["id"] + || body["packetRevision"] != packet["revision"] + { + return Err("review is stale or identifies another request".into()); + } + if !state["receipt"].is_null() { + return if state["receipt"]["submission"] == *body { + Ok(state["receipt"].clone()) + } else { + Err("this round already has different feedback".into()) + }; + } + sources_current(&state)?; + let decisions = body["decisions"] + .as_object() + .ok_or("decisions must be an object")?; + let components = packet["components"] + .as_array() + .ok_or("missing components")?; + let mut approved = 0; + let mut revisions = 0; + for (id, d) in decisions { + let c = components + .iter() + .find(|c| c["id"] == *id) + .ok_or("unknown component")?; + if d["revision"] != c["revision"] { + return Err("stale component revision".into()); + } + if !d["feedback"].is_string() + || d["feedback"].as_str().unwrap().len() > 8000 + || !d["split"].is_boolean() + { + return Err("invalid component feedback".into()); + } + match d["action"].as_str() { + Some("approve") if d["split"] == false => approved += 1, + Some("revise") => revisions += 1, + _ => return Err("invalid decision action".into()), + } + } + let missing = body["missing"] + .as_array() + .ok_or("missing must be an array")?; + let mut missing_ids = std::collections::BTreeSet::new(); + for m in missing { + if !valid_box(&m["box"]) + || string(m, "name")?.trim().is_empty() + || !m["feedback"].is_string() + || !missing_ids.insert(string(m, "id")?) + { + return Err("invalid missing component".into()); + } + } + if missing.len() > 200 || !body["inventoryConfirmed"].is_boolean() { + return Err("invalid inventory confirmation".into()); + } + let has_feedback = revisions > 0 || !missing.is_empty(); + if !has_feedback && (approved != components.len() || body["inventoryConfirmed"] != true) { + return Err("approve every component and confirm inventory completeness".into()); + } + let receipt = json!({"schemaVersion":1,"reviewer":"local-browser","visualDecision":if has_feedback{"changes-requested"}else{"approved"},"captureVerified":state["capture"]["schema"]=="native-component-previews-v1","capture":state["capture"],"submission":body}); + state["draft"] = json!({"packetRevision":body["packetRevision"],"decisions":decisions,"missing":missing,"inventoryConfirmed":body["inventoryConfirmed"]}); + state["receipt"] = receipt.clone(); + write(&dir.join("current.json"), &state)?; + // current.json is the authoritative atomic commit; a receipt export is not approval authority. + Ok(receipt) +} diff --git a/crates/context/src/component_review/tests.rs b/crates/context/src/component_review/tests.rs new file mode 100644 index 000000000..dcceb86e3 --- /dev/null +++ b/crates/context/src/component_review/tests.rs @@ -0,0 +1,473 @@ +use super::{manifest, server, store}; +use serde_json::{Value, json}; +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, +}; +static NEXT: AtomicUsize = AtomicUsize::new(0); +struct Fixture { + root: PathBuf, + project: PathBuf, + store: PathBuf, +} +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "impeccable-component-review-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let project = root.join("project"); + let store = root.join("store"); + fs::create_dir_all(&project).unwrap(); + for (name, body) in [ + ("comp.png", b"comp".as_slice()), + ("art.png", b"art"), + ("control.html", b""), + ("shared.css", b"button{color:red}"), + ] { + fs::write(project.join(name), body).unwrap(); + } + Self { + root, + project, + store, + } + } + fn manifest(&self) -> Value { + json!({"schemaVersion":1,"id":"hero","title":"Hero review","comp":{"path":"comp.png","width":100,"height":100},"components":[{"id":"art","name":"Art","medium":"Raster","note":"Illustration","box":{"x":0,"y":0,"w":0.5,"h":1},"preview":{"kind":"image","path":"art.png"},"dependencies":[]},{"id":"control","name":"Control","medium":"HTML","note":"Semantic control","box":{"x":0.5,"y":0,"w":0.5,"h":1},"preview":{"kind":"page","path":"control.html"},"dependencies":["shared.css"]}]}) + } + fn prepare(&self) -> PathBuf { + store::prepare(&self.store, &self.project, &self.manifest()).unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} +fn approve(state: &Value) -> Value { + let mut decisions = serde_json::Map::new(); + for c in state["packet"]["components"].as_array().unwrap() { + decisions.insert( + c["id"].as_str().unwrap().into(), + json!({"revision":c["revision"],"action":"approve","feedback":"","split":false}), + ); + } + json!({"schemaVersion":1,"requestId":state["packet"]["id"],"packetRevision":state["packet"]["revision"],"decisions":decisions,"missing":[],"inventoryConfirmed":true}) +} +#[test] +fn immutable_snapshots_and_idempotent_feedback_survive_reload() { + let f = Fixture::new(); + let dir = f.prepare(); + let state = store::read(&dir.join("current.json")).unwrap(); + let body = approve(&state); + let receipt = store::submit(&dir, &body).unwrap(); + assert_eq!(store::submit(&dir, &body).unwrap(), receipt); + assert_eq!( + store::read(&dir.join("current.json")).unwrap()["receipt"], + receipt + ); + assert_eq!(receipt["reviewer"], "local-browser"); + assert_eq!(receipt["captureVerified"], false); + let mut conflict = body; + conflict["decisions"]["art"]["feedback"] = json!("different"); + assert!( + store::submit(&dir, &conflict) + .unwrap_err() + .contains("already") + ); +} +#[test] +fn source_change_rejects_pending_approval_and_invalidates_only_affected_components() { + let f = Fixture::new(); + let dir = f.prepare(); + let first = store::read(&dir.join("current.json")).unwrap(); + store::submit(&dir, &approve(&first)).unwrap(); + fs::write(f.project.join("art.png"), b"new art").unwrap(); + let dir = f.prepare(); + let next = store::read(&dir.join("current.json")).unwrap(); + assert_ne!(first["packet"]["revision"], next["packet"]["revision"]); + assert!(next["draft"]["decisions"]["art"].is_null()); + assert_eq!(next["draft"]["decisions"]["control"]["action"], "approve"); + assert_eq!(next["draft"]["inventoryConfirmed"], false); + assert!( + store::submit(&dir, &approve(&first)) + .unwrap_err() + .contains("stale") + ); + fs::write(f.project.join("shared.css"), b"changed again").unwrap(); + assert!( + store::submit(&dir, &approve(&next)) + .unwrap_err() + .contains("stale") + ); +} +#[test] +fn missing_regions_persist_across_rounds_and_old_receipts_are_preserved() { + let f = Fixture::new(); + let dir = f.prepare(); + let first = store::read(&dir.join("current.json")).unwrap(); + let mut body = approve(&first); + body["missing"] = json!([{"id":"missing-1","name":"Brushwork","feedback":"Restore it","box":{"x":0.2,"y":0.2,"w":0.1,"h":0.1}}]); + body["inventoryConfirmed"] = json!(false); + let receipt = store::submit(&dir, &body).unwrap(); + fs::write(f.project.join("art.png"), b"repair").unwrap(); + f.prepare(); + let next = store::read(&dir.join("current.json")).unwrap(); + assert_eq!(next["draft"]["missing"], body["missing"]); + let history = store::read(&dir.join(format!( + "revisions/{}.json", + first["packet"]["revision"].as_str().unwrap() + ))) + .unwrap(); + assert_eq!(history["receipt"], receipt); +} +#[test] +fn refusal_paths_cannot_be_turned_into_approval() { + let f = Fixture::new(); + let dir = f.prepare(); + let state = store::read(&dir.join("current.json")).unwrap(); + let mut body = approve(&state); + body["inventoryConfirmed"] = json!(false); + assert!(store::submit(&dir, &body).is_err()); + body["inventoryConfirmed"] = json!(true); + body["decisions"]["art"]["action"] = json!("skip"); + assert!(store::submit(&dir, &body).is_err()); + body["decisions"]["art"]["action"] = json!("approve"); + body["decisions"]["art"]["revision"] = json!("invented"); + assert!(store::submit(&dir, &body).is_err()); + assert!(store::read(&dir.join("current.json")).unwrap()["receipt"].is_null()); +} +#[test] +fn files_are_confined_and_store_is_outside_project() { + let f = Fixture::new(); + let mut manifest = f.manifest(); + manifest["components"][0]["preview"]["path"] = json!("../outside.png"); + assert!(manifest::freeze(&f.project, &manifest).is_err()); + assert!( + store::prepare( + &f.project.join("forged-approvals"), + &f.project, + &f.manifest() + ) + .is_err() + ); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&f.root, f.project.join("outside")).unwrap(); + manifest["components"][0]["preview"]["path"] = json!("outside/project/art.png"); + let frozen = manifest::freeze(&f.project, &manifest); + assert!(frozen.is_ok()); + std::os::unix::fs::symlink("/etc/hosts", f.project.join("leak")).unwrap(); + manifest["components"][0]["preview"]["path"] = json!("leak"); + assert!(manifest::freeze(&f.project, &manifest).is_err()); + } +} +#[test] +fn malformed_maps_and_cross_origin_posts_are_rejected() { + let f = Fixture::new(); + let mut manifest = f.manifest(); + manifest["components"][0]["box"]["w"] = json!(0); + assert!(manifest::freeze(&f.project, &manifest).is_err()); + assert!(server::authorized( + "POST", + Some("127.0.0.1:4321"), + Some("http://127.0.0.1:4321"), + Some("same-origin"), + 4321 + )); + for origin in [None, Some("null"), Some("https://evil.example")] { + assert!(!server::authorized( + "POST", + Some("127.0.0.1:4321"), + origin, + Some("same-origin"), + 4321 + )); + } + assert!(!server::authorized( + "GET", + Some("evil.example"), + None, + None, + 4321 + )); + assert!(super::decode_path("a%20b.png").is_ok()); + assert!(super::decode_path("%00").is_err()); +} + +#[test] +fn prepare_cli_reports_an_existing_receipt_instead_of_requesting_review_again() { + let f = Fixture::new(); + fs::write( + f.project.join("review.json"), + serde_json::to_vec(&f.manifest()).unwrap(), + ) + .unwrap(); + let args = vec![ + "prepare".into(), + "--manifest".into(), + "review.json".into(), + "--store".into(), + f.store.to_string_lossy().into_owned(), + ]; + let invoke = || { + let (mut io, captured) = + impeccable_common::Io::captured("", f.project.clone(), Default::default()); + assert_eq!(super::run(&args, &mut io), 0); + let result = serde_json::from_slice::(&captured.stdout.borrow()).unwrap(); + result + }; + let initial = invoke(); + assert_eq!(initial["status"], "awaiting-review"); + let dir = f.store.join(initial["session"].as_str().unwrap()); + let first = store::read(&dir.join("current.json")).unwrap(); + store::submit(&dir, &approve(&first)).unwrap(); + assert_eq!(invoke()["status"], "approved"); +} + +#[test] +fn repair_history_records_feedback_changes_and_removed_components() { + let f = Fixture::new(); + let dir = f.prepare(); + let first = store::read(&dir.join("current.json")).unwrap(); + let mut body = approve(&first); + body["decisions"]["art"]["action"] = json!("revise"); + body["decisions"]["art"]["feedback"] = json!("Preserve the motif"); + store::submit(&dir, &body).unwrap(); + fs::write(f.project.join("art.png"), b"repair").unwrap(); + f.prepare(); + let next = store::read(&dir.join("current.json")).unwrap(); + assert_eq!(next["history"]["packet"]["round"], 1); + assert_eq!( + next["history"]["draft"]["decisions"]["art"]["feedback"], + "Preserve the motif" + ); + assert_eq!( + next["history"]["changes"]["art"]["files"], + json!(["art.png"]) + ); + assert_eq!(next["history"]["changes"]["control"]["kind"], "unchanged"); + let mut manifest = f.manifest(); + manifest["components"].as_array_mut().unwrap().remove(1); + store::prepare(&f.store, &f.project, &manifest).unwrap(); + let removed = store::read(&dir.join("current.json")).unwrap(); + assert_eq!(removed["history"]["removed"][0]["id"], "control"); + assert_eq!(removed["draft"]["inventoryConfirmed"], false); +} +#[test] +fn reverting_to_old_content_cannot_reuse_an_old_round_or_approval() { + let f = Fixture::new(); + let dir = f.prepare(); + let first = store::read(&dir.join("current.json")).unwrap(); + store::submit(&dir, &approve(&first)).unwrap(); + fs::write(f.project.join("art.png"), b"repair").unwrap(); + f.prepare(); + fs::write(f.project.join("art.png"), b"art").unwrap(); + f.prepare(); + let third = store::read(&dir.join("current.json")).unwrap(); + assert_eq!(third["packet"]["round"], 3); + assert_ne!(first["packet"]["revision"], third["packet"]["revision"]); + assert!(store::submit(&dir, &approve(&first)).is_err()); + assert!(third["draft"]["decisions"]["art"].is_null()); + let archive = store::read(&dir.join(format!( + "revisions/{}.json", + first["packet"]["revision"].as_str().unwrap() + ))) + .unwrap(); + assert_eq!(archive["packet"]["round"], 1); + assert_eq!(archive["receipt"]["visualDecision"], "approved"); +} + +#[test] +fn preparing_again_before_a_reply_preserves_outstanding_feedback_and_carried_approvals() { + let f = Fixture::new(); + let dir = f.prepare(); + let first = store::read(&dir.join("current.json")).unwrap(); + let mut body = approve(&first); + body["decisions"]["art"]["action"] = json!("revise"); + body["decisions"]["art"]["feedback"] = json!("Keep the motif"); + store::submit(&dir, &body).unwrap(); + fs::write(f.project.join("art.png"), b"repair one").unwrap(); + f.prepare(); + fs::write(f.project.join("art.png"), b"repair two").unwrap(); + f.prepare(); + let third = store::read(&dir.join("current.json")).unwrap(); + assert_eq!( + third["history"]["feedback"]["art"]["decision"]["feedback"], + "Keep the motif" + ); + assert_eq!(third["history"]["feedback"]["art"]["round"], 1); + assert_eq!(third["history"]["changes"]["control"]["carried"], true); + store::submit(&dir, &approve(&third)).unwrap(); + fs::write(f.project.join("art.png"), b"another version").unwrap(); + f.prepare(); + let fourth = store::read(&dir.join("current.json")).unwrap(); + assert!(fourth["history"]["feedback"]["art"].is_null()); +} + +#[test] +fn failed_native_capture_does_not_replace_the_current_review() { + struct Refuse; + impl super::capture::ComponentCapturer for Refuse { + fn capture( + &mut self, + _: &mut Value, + _: &std::collections::BTreeMap>, + ) -> Result { + Err("missing stylesheet".into()) + } + } + let f = Fixture::new(); + let dir = f.prepare(); + let before = fs::read(dir.join("current.json")).unwrap(); + assert!( + store::prepare_captured(&f.store, &f.project, &f.manifest(), Some(&mut Refuse)) + .unwrap_err() + .contains("missing stylesheet") + ); + assert_eq!(fs::read(dir.join("current.json")).unwrap(), before); +} +#[test] +fn producer_capture_claims_are_never_authority() { + let f = Fixture::new(); + let mut input = f.manifest(); + input["captureVerified"] = json!(true); + input["capture"] = json!({"schema":"native-component-previews-v1"}); + input["components"][0]["capture"] = json!({"verified":true}); + input["components"][0]["preview"]["sourceKind"] = json!("page"); + let dir = store::prepare(&f.store, &f.project, &input).unwrap(); + let state = store::read(&dir.join("current.json")).unwrap(); + assert!(state["packet"]["capture"].is_null()); + assert!(state["packet"]["components"][0]["capture"].is_null()); + assert!(state["packet"]["components"][0]["preview"]["sourceKind"].is_null()); + assert_eq!( + store::submit(&dir, &approve(&state)).unwrap()["captureVerified"], + false + ); +} + +#[test] +fn native_capture_outputs_are_immutable_and_source_changes_invalidate_approval() { + struct Renderer; + impl super::capture::ComponentCapturer for Renderer { + fn capture( + &mut self, + packet: &mut Value, + _: &std::collections::BTreeMap>, + ) -> Result { + // A trusted in-process renderer double, never a producer JSON claim. + packet["components"][1]["preview"] = json!({"kind":"image","url":"/files/_review_captures/control.png","sourceKind":"page"}); + Ok(super::capture::CapturedPreviews { + files: std::collections::BTreeMap::from([( + "_review_captures/control.png".into(), + b"native pixels".to_vec(), + )]), + evidence: json!({"schema":"native-component-previews-v1","components":[{"id":"art"},{"id":"control"}]}), + }) + } + } + let f = Fixture::new(); + let dir = + store::prepare_captured(&f.store, &f.project, &f.manifest(), Some(&mut Renderer)).unwrap(); + let state = store::read(&dir.join("current.json")).unwrap(); + store::sources_current(&state).unwrap(); + assert!( + state["sources"] + .get("_review_captures/control.png") + .is_none() + ); + let receipt = store::submit(&dir, &approve(&state)).unwrap(); + assert_eq!(receipt["captureVerified"], true); + assert_eq!(receipt["reviewer"], "local-browser"); + fs::write(f.project.join("art.png"), b"changed").unwrap(); + assert!(store::sources_current(&state).is_err()); + store::prepare_captured(&f.store, &f.project, &f.manifest(), Some(&mut Renderer)).unwrap(); + let next = store::read(&dir.join("current.json")).unwrap(); + assert!(next["draft"]["decisions"]["art"].is_null()); + assert_eq!(next["draft"]["decisions"]["control"]["action"], "approve"); + assert_ne!(state["packet"]["revision"], next["packet"]["revision"]); + assert_eq!( + store::submit(&dir, &approve(&next)).unwrap()["captureVerified"], + true + ); +} + +#[test] +fn changing_manifest_without_prepare_rejects_review_submission() { + let f = Fixture::new(); + let input = f.manifest(); + let file = f.project.join("review.json"); + fs::write(&file, serde_json::to_vec(&input).unwrap()).unwrap(); + let dir = store::prepare_file(&f.store, &f.project, "review.json", None).unwrap(); + let state = store::read(&dir.join("current.json")).unwrap(); + let mut changed = input; + changed["components"][0]["box"]["w"] = json!(0.4); + fs::write(&file, serde_json::to_vec(&changed).unwrap()).unwrap(); + assert!( + store::submit(&dir, &approve(&state)) + .unwrap_err() + .contains("stale") + ); +} + +#[test] +fn versioned_packet_keeps_submitted_round_when_current_advances() { + let f = Fixture::new(); + let dir = f.prepare(); + let state = store::read(&dir.join("current.json")).unwrap(); + let revision = state["packet"]["revision"].as_str().unwrap(); + let receipt = store::submit(&dir, &approve(&state)).unwrap(); + assert_eq!( + server::packet_state(&dir, Some(revision)).unwrap()["receipt"], + receipt + ); + fs::write(f.project.join("art.png"), b"new artwork").unwrap(); + f.prepare(); + let old = server::packet_state(&dir, Some(revision)).unwrap(); + assert_eq!(old["packet"], state["packet"]); + assert_eq!(old["receipt"], receipt); + assert_eq!(old["historical"], true); + assert!(old["sourceStatus"].is_null()); + assert_ne!( + server::packet_state(&dir, None).unwrap()["packet"]["revision"], + revision + ); + assert!(server::packet_state(&dir, Some("../../current")).is_err()); + assert!(store::submit(&dir, &approve(&state)).is_err()); +} + +#[test] +fn verify_requires_native_approval_and_current_manifest_and_dependencies() { + struct Renderer; + impl super::capture::ComponentCapturer for Renderer { + fn capture(&mut self, packet: &mut Value, _: &std::collections::BTreeMap>) -> Result { + packet["components"][1]["preview"] = json!({"kind":"image","url":"/files/_review_captures/control.png","sourceKind":"page"}); + Ok(super::capture::CapturedPreviews { + files: std::collections::BTreeMap::from([("_review_captures/control.png".into(), b"native pixels".to_vec())]), + evidence: json!({"schema":"native-component-previews-v1","components":[{"id":"art"},{"id":"control"}]}), + }) + } + } + let f = Fixture::new(); + fs::write(f.project.join("review.json"), f.manifest().to_string()).unwrap(); + let dir = store::prepare_file(&f.store,&f.project,"review.json",Some(&mut Renderer)).unwrap(); + assert!(super::verify::approved(&f.store,&f.project,"review.json").is_err()); + let state = store::read(&dir.join("current.json")).unwrap(); + let mut needs_work = approve(&state); + needs_work["decisions"]["art"]["action"] = json!("revise"); + needs_work["decisions"]["art"]["feedback"] = json!("Wrong shape"); + store::submit(&dir,&needs_work).unwrap(); + assert!(super::verify::approved(&f.store,&f.project,"review.json").is_err()); + fs::write(f.project.join("art.png"), b"repaired art").unwrap(); + let dir = store::prepare_file(&f.store,&f.project,"review.json",Some(&mut Renderer)).unwrap(); + let state = store::read(&dir.join("current.json")).unwrap(); + store::submit(&dir,&approve(&state)).unwrap(); + assert_eq!(super::verify::approved(&f.store,&f.project,"review.json").unwrap()["visualDecision"],"approved"); + fs::write(f.project.join("other.json"), f.manifest().to_string()).unwrap(); + assert!(super::verify::approved(&f.store,&f.project,"other.json").unwrap_err().contains("bind")); + fs::write(f.project.join("shared.css"), b"changed after approval").unwrap(); + assert!(super::verify::approved(&f.store,&f.project,"review.json").unwrap_err().contains("changed")); +} diff --git a/crates/context/src/component_review/verify.rs b/crates/context/src/component_review/verify.rs new file mode 100644 index 000000000..ee1011070 --- /dev/null +++ b/crates/context/src/component_review/verify.rs @@ -0,0 +1,28 @@ +//! Read-only approval verification against the current native capture and source bytes. +use super::{manifest::string, store}; +use serde_json::Value; +use std::path::Path; + +pub fn approved(store_root: &Path, project: &Path, manifest_path: &str) -> Result { + let project = project.canonicalize().map_err(|e| e.to_string())?; + let manifest_file = project.join(super::manifest::relative(manifest_path)?); + let manifest = store::read(&manifest_file)?; + let directory = store::session_dir(store_root, &project, string(&manifest, "id")?); + let _guard = store::lock(&directory)?; + let state = store::read(&directory.join("current.json"))?; + store::sources_current(&state)?; + if state["capture"]["schema"] != "native-component-previews-v1" + || state["receipt"]["captureVerified"] != true + || state["receipt"]["visualDecision"] != "approved" + || state["receipt"]["submission"]["packetRevision"] != state["packet"]["revision"] + || state["receipt"]["submission"]["requestId"] != state["packet"]["id"] + { + return Err("component review is pending or needs work; await the user, then verify again".into()); + } + // A different manifest with the same ID must not borrow this session's approval. + let source = state["sources"][manifest_path].as_str().ok_or("review did not bind this manifest")?; + if super::manifest::digest(&std::fs::read(manifest_file).map_err(|e| e.to_string())?) != source { + return Err("review manifest changed; capture a new round".into()); + } + Ok(state["receipt"].clone()) +} diff --git a/crates/context/src/lib.rs b/crates/context/src/lib.rs index 578329372..dd45f61a1 100644 --- a/crates/context/src/lib.rs +++ b/crates/context/src/lib.rs @@ -47,3 +47,5 @@ pub use generate_image::run as run_generate_image; pub mod question_page; pub mod serve_question; pub use serve_question::run as run_serve_question; + +pub mod component_review; diff --git a/crates/hook/Cargo.toml b/crates/hook/Cargo.toml index a68e5d926..3f46887cd 100644 --- a/crates/hook/Cargo.toml +++ b/crates/hook/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] impeccable-common = { workspace = true } +impeccable-comp-verbs = { workspace = true } impeccable-core = { workspace = true } impeccable-detect = { workspace = true } impeccable-context = { workspace = true } diff --git a/crates/hook/src/build_completion.rs b/crates/hook/src/build_completion.rs new file mode 100644 index 000000000..9d64895fb --- /dev/null +++ b/crates/hook/src/build_completion.rs @@ -0,0 +1,64 @@ +//! Completion feedback for a build explicitly owned by this native session. +use crate::hook_lib::{Cache, Runtime, ensure_session, persist_cache}; +use impeccable_comp_verbs::completion; +use serde_json::{json, Value}; +use std::path::Path; + +fn literal_session_id(session: &str) -> bool { + !session.is_empty() && session.len() <= 200 + && session.bytes().all(|b| b.is_ascii_alphanumeric() || b"_-:.".contains(&b)) +} + +/// Gemini supplies identity to hooks, but not ordinary shell tool processes. +/// Its native BeforeTool argument transform carries that metadata into the +/// POSIX shell without adding model instructions or changing the command body. +/// Windows shell semantics require a separate transport; decline there. +pub fn gemini_shell_identity(rt: &Runtime, event: &serde_json::Map) -> Option { + if rt.win32 || event.get("tool_name")?.as_str()? != "run_shell_command" { return None; } + let session = event.get("session_id")?.as_str()?; + if !literal_session_id(session) { return None; } + let command = event.get("tool_input")?.get("command")?.as_str()?; + let prefix = format!("export IMPECCABLE_SESSION_ID='{session}'\n"); + if command.starts_with(&prefix) { return None; } + Some(json!({"hookSpecificOutput":{"tool_input":{"command":format!("{prefix}{command}")}}}).to_string()) +} + +pub fn reminder(rt: &Runtime, cwd: &str, session: &str, active: bool, cache: &mut Cache) -> Option { + if session == "unknown" || session.is_empty() { return None; } + let root = Path::new(cwd); + let state: Value = serde_json::from_str(&std::fs::read_to_string(root.join(".impeccable/build/state.json")).ok()?).ok()?; + let report = completion::report(root, Some(&state), Some(session)); + if report["canContinue"] != true { return None; } + let build = state.get("startedAt")?.as_str()?; + let artifact = state.get("artifact")?.as_str()?; + let key = format!("{build}:{artifact}"); + let current_hash = completion::artifact_hash(root, &state)?; + let old = ensure_session(cache, session).get("buildCompletionNotice").cloned().unwrap_or(Value::Null); + let same_build = old["build"] == key; + let count = if same_build { old["count"].as_u64().unwrap_or(0) } else { 0 }; + // Do not take over a continuation issued by another Stop hook. On a new + // turn, unchanged old work must not consume another reminder either. + if count >= 3 || (active && count == 0) + || (!active && same_build && old["artifactSha256"] == current_hash) { return None; } + ensure_session(cache, session).insert("buildCompletionNotice".into(), json!({ + "build":key, "count":count+1, "artifactSha256":current_hash + })); + // Never emit an unbounded continuation if the counter cannot be saved. + if !persist_cache(rt, cwd, cache) { return None; } + let open = report["openPhases"].as_array()?.iter().filter_map(Value::as_str).collect::>().join(", "); + let evidence = if report["status"] == "changed-after-finish" { + "The entry artifact changed after the recorded final review.".to_string() + } else { format!("Open phases: {open}.") }; + Some(format!("Comp build for {artifact} is unfinished. {evidence} Complete the remaining checks and record the actual finish disposition. If work is blocked, report what remains unresolved. This is completion pass {} of 3; existing fidelity gates still apply.", count+1)) +} + +/// Claude exposes a session-specific environment file at SessionStart. Store +/// only identity, not prompts or policy. Codex supplies CODEX_THREAD_ID itself. +pub fn persist_session_identity(rt: &Runtime, session: &str) { + use std::io::Write; + if !literal_session_id(session) { return; } + let Some(path) = rt.env("CLAUDE_ENV_FILE").filter(|p| !p.is_empty()) else { return; }; + if let Ok(mut file) = std::fs::OpenOptions::new().append(true).create(true).open(path) { + let _ = writeln!(file, "export IMPECCABLE_SESSION_ID='{session}'"); + } +} diff --git a/crates/hook/src/hook.rs b/crates/hook/src/hook.rs index bb895e36c..2c51326bf 100644 --- a/crates/hook/src/hook.rs +++ b/crates/hook/src/hook.rs @@ -602,15 +602,7 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult { // `stop_hook_active`; Grok sends `stopHookActive`, copied onto the // snake_case field by the normalizer. Cursor and GitHub Copilot omit // the field, so the strict `=== true` is a no-op for them. - if event.get("stop_hook_active") == Some(&Value::Bool(true)) { - return result( - &audit, - vec![ - ("skipped", Value::from("stop-hook-active")), - ("durationMs", ms_since(started)), - ], - ); - } + let stop_hook_active = event.get("stop_hook_active") == Some(&Value::Bool(true)); // JS: Grok fires Stop twice: `end_turn` (the gate that can inject // additionalContext) then an observe-only `shutdown`. A second deep // pass would re-emit the same findings. Claude omits `reason`; only @@ -649,6 +641,20 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult { ); } let mut cache = read_cache(&project_cwd); + if matches!(harness, "claude" | "codex" | "gemini") { + if let Some(message) = crate::build_completion::reminder(rt, &project_cwd, &session_id, stop_hook_active, &mut cache) { + return RunResult { + stdout: payload(&message, "Stop", harness), + audit: with(&audit, vec![("kind", Value::from("build-completion")), ("emitted", Value::Bool(true)), ("durationMs", ms_since(started))]), + }; + } + } + if stop_hook_active { + return result(&audit, vec![("skipped", Value::from("stop-hook-active")), ("durationMs", ms_since(started))]); + } + if truthy(rt.env("IMPECCABLE_HOOK_COMPLETION_ONLY")) { + return result(&audit, vec![("skipped", Value::from("no-build-continuation")), ("durationMs", ms_since(started))]); + } let touched = touched_files(&cache, &session_id); if touched.is_empty() { return result( @@ -844,6 +850,25 @@ fn is_stop_event(stdin: &str) -> bool { /// `impeccable hook` (hook.mjs main). Returns the exit code (always 0). pub fn run(rt: &Runtime, stdin: &str, io: &mut impeccable_common::Io) -> i32 { + if let Ok(Value::Object(event)) = serde_json::from_str::(stdin) { + if event.get("hook_event_name").and_then(Value::as_str) == Some("BeforeTool") + && resolve_harness(rt, Some(&event)) == "gemini" { + if !truthy(rt.env("IMPECCABLE_HOOK_DISABLED")) && read_config(&rt.proc_cwd).enabled { + if let Some(output) = crate::build_completion::gemini_shell_identity(rt, &event) { + io.out(&format!("{output}\n")); + } + } + return 0; + } + if event.get("hook_event_name").and_then(Value::as_str) == Some("SessionStart") { + if !truthy(rt.env("IMPECCABLE_HOOK_DISABLED")) && resolve_harness(rt, Some(&event)) == "claude" { + if let Some(session) = event.get("session_id").and_then(Value::as_str) { + crate::build_completion::persist_session_identity(rt, session); + } + } + return 0; + } + } // JS: process.env.IMPECCABLE_HOOK_DEPTH = process.env.IMPECCABLE_HOOK_DEPTH || '1' // is exported for child processes; this binary spawns none, so the // pre-mutation snapshot in `rt.env` is the only value that matters. diff --git a/crates/hook/src/hook_lib.rs b/crates/hook/src/hook_lib.rs index 160b6994b..1e6bb1ca6 100644 --- a/crates/hook/src/hook_lib.rs +++ b/crates/hook/src/hook_lib.rs @@ -1795,7 +1795,7 @@ pub fn payload(text: &str, event_name: &str, harness: &str) -> String { out.insert("additional_context".into(), Value::String(text.to_string())); } else if harness == "github" { out.insert("additionalContext".into(), Value::String(text.to_string())); - } else if harness == "codex" && event_name == "Stop" { + } else if matches!(harness, "codex" | "gemini") && event_name == "Stop" { // Codex shares Claude Code's PostToolUse additional-context shape, // but its Stop schema rejects unknown fields. Findings that should // continue the turn must be a top-level blocking decision (#603). @@ -1803,7 +1803,7 @@ pub fn payload(text: &str, event_name: &str, harness: &str) -> String { if js::trim(text).is_empty() { return String::new(); } - out.insert("decision".into(), Value::String("block".to_string())); + out.insert("decision".into(), Value::String(if harness == "gemini" { "deny" } else { "block" }.to_string())); out.insert("reason".into(), Value::String(text.to_string())); } else { let mut inner = Map::new(); @@ -1885,9 +1885,13 @@ pub fn resolve_harness(rt: &Runtime, event: Option<&Map>) -> &'st Some("grok") => return "grok", Some("claude") => return "claude", Some("codex") => return "codex", + Some("gemini") => return "gemini", _ => {} } if let Some(ev) = event { + if matches!(str_field(ev, "hook_event_name"), Some("BeforeTool" | "AfterAgent")) { + return "gemini"; + } // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` // and no snake_case pair. GitHub Copilot sends camelCase // `toolName`/`toolArgs`. Check Grok first: the old GitHub heuristic @@ -1945,7 +1949,7 @@ pub fn is_stop_event(ev: &Map) -> bool { Some(v) => Some(v), None => ev.get("hookEventName"), }; - matches!(name, Some(Value::String(s)) if js::to_lower_case(s) == "stop") + matches!(name, Some(Value::String(s)) if matches!(js::to_lower_case(s).as_str(), "stop" | "afteragent")) } /// JS: parseGitHubToolArgs(toolArgs) diff --git a/crates/hook/src/lib.rs b/crates/hook/src/lib.rs index 8584d841f..913dc5a59 100644 --- a/crates/hook/src/lib.rs +++ b/crates/hook/src/lib.rs @@ -14,6 +14,7 @@ pub mod admin; pub mod before_edit; pub mod hook; pub mod hook_lib; +mod build_completion; mod stop_baseline; pub mod util; diff --git a/crates/hook/tests/hook_tests.rs b/crates/hook/tests/hook_tests.rs index 3ffffd376..143162301 100644 --- a/crates/hook/tests/hook_tests.rs +++ b/crates/hook/tests/hook_tests.rs @@ -19,6 +19,115 @@ use serde_json::{json, Map, Value}; static HTML: MissingHtmlEngine = MissingHtmlEngine; +#[test] +#[cfg(unix)] +fn gemini_before_tool_preserves_command_body_and_only_carries_literal_identity() { + let t = Tmp::new(); + let command = "printf '%s\\n' \"$IMPECCABLE_SESSION_ID\"\nexit 7"; + for (name, session, disabled, expected) in [ + ("run_shell_command", "owner-123", false, true), + ("read_file", "owner-123", false, false), + ("run_shell_command", "bad'$(touch unsafe)", false, false), + ("run_shell_command", "owner-123", true, false), + ] { + let r = rt_with(&t.path(), env(&[("IMPECCABLE_HOOK_DISABLED", if disabled { "1" } else { "" })])); + let event = json!({"hook_event_name":"BeforeTool", "session_id":session, + "cwd":t.path(), "tool_name":name, "tool_input":{"command":command, "timeout":12}}); + let (mut io, capture) = Io::captured("", t.0.clone(), HashMap::new()); + hook::run(&r, &event.to_string(), &mut io); + let stdout = String::from_utf8(capture.stdout.borrow().clone()).unwrap(); + if expected { + let output: Value = serde_json::from_str(&stdout).unwrap(); + let rewritten = output["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap(); + assert_eq!(rewritten, format!("export IMPECCABLE_SESSION_ID='owner-123'\n{command}")); + assert!(output["hookSpecificOutput"].get("additionalContext").is_none()); + #[cfg(unix)] { + let execution = std::process::Command::new("sh").args(["-c", rewritten]).output().unwrap(); + assert_eq!(execution.status.code(), Some(7)); + assert_eq!(String::from_utf8(execution.stdout).unwrap(), "owner-123\n"); + } + } else { assert!(stdout.is_empty()); } + } +} + +#[test] +fn gemini_after_agent_uses_native_retry_contract_and_session_scope() { + let t = Tmp::new(); + t.write("index.html", "
In progress
"); + t.write(".impeccable/build/state.json", &json!({ + "sessionId":"gemini-owner", "artifact":"index.html", "startedAt":"gemini-build", + "phases":{"hero":{"status":"open"}}, "finish":null + }).to_string()); + let r = rt(&t.path()); + for (session, active, expected) in [("other", false, false), ("gemini-owner", false, true), + ("gemini-owner", true, true), ("gemini-owner", true, true), ("gemini-owner", true, false)] { + let event = json!({"hook_event_name":"AfterAgent", "session_id":session, + "cwd":t.path(), "stop_hook_active":active}); + assert!(is_stop_event(event.as_object().unwrap())); + let result = hook::run_stop_hook(&r, &event.to_string()); + if expected { + let output: Value = serde_json::from_str(&result.stdout).unwrap(); + assert_eq!(output["decision"], "deny"); + assert!(output["reason"].as_str().unwrap().contains("Comp build")); + assert!(output.get("hookSpecificOutput").is_none()); + } else { assert!(result.stdout.is_empty()); } + } +} + +#[test] +fn session_start_persists_only_a_literal_session_identity() { + let t = Tmp::new(); + let env_path = t.write("session.env", "export EXISTING=kept\n"); + let r = rt_with(&t.path(), env(&[("CLAUDE_ENV_FILE", &env_path)])); + let mut io = Io::captured("", t.0.clone(), HashMap::new()).0; + for session in ["owner-123", "bad'$(touch unsafe)"] { + let event = json!({"hook_event_name":"SessionStart", "session_id":session, "cwd":t.path()}).to_string(); + assert_eq!(hook::run(&r, &event, &mut io), 0); + } + assert_eq!(t.read("session.env"), "export EXISTING=kept\nexport IMPECCABLE_SESSION_ID='owner-123'\n"); +} + +#[test] +fn unfinished_comp_stop_is_owned_bounded_and_does_not_reopen_on_unrelated_turn() { + let t = Tmp::new(); + t.write("index.html", "
In progress
"); + t.write(".impeccable/build/state.json", &json!({ + "tool":"build-phase", "version":2, "startedAt":"build-one", + "sessionId":"owner", "artifact":"index.html", "finish":null, + "phases":{"hero":{"status":"open"}} + }).to_string()); + let r = rt(&t.path()); + assert!(hook::run_stop_hook(&r, &stop_event(&t.path(), "other")).stdout.is_empty()); + let first = hook::run_stop_hook(&r, &stop_event(&t.path(), "owner")); + assert!(first.stdout.contains("Comp build"), "{}", first.stdout); + // A new unrelated turn must not spend another reminder on unchanged work. + assert!(hook::run_stop_hook(&r, &stop_event(&t.path(), "owner")).stdout.is_empty()); + let active = json!({"session_id":"owner","cwd":t.path(),"hook_event_name":"Stop","stop_hook_active":true}).to_string(); + assert!(hook::run_stop_hook(&r, &active).stdout.contains("Comp build")); + assert!(hook::run_stop_hook(&r, &active).stdout.contains("Comp build")); + assert!(hook::run_stop_hook(&r, &active).stdout.is_empty()); +} + +#[test] +fn legacy_comp_state_never_implicitly_claims_the_current_session() { + let t = Tmp::new(); + t.write("index.html", "
Old work
"); + t.write(".impeccable/build/state.json", &json!({"artifact":"index.html","startedAt":"old","phases":{}}).to_string()); + assert!(hook::run_stop_hook(&rt(&t.path()), &stop_event(&t.path(), "new")).stdout.is_empty()); +} + +#[test] +fn completion_only_transport_does_not_duplicate_detector_feedback() { + let t = Tmp::new(); + t.write("package.json", "{}"); + let file = t.write("card.css", SIDE_TAB_CSS); + let r = rt_with(&t.path(), env(&[("IMPECCABLE_HOOK_COMPLETION_ONLY", "1")])); + hook::run_hook(&r, &edit_with_original(&t.path(), &file, "owner", ".card {}\n", ".card {}\n", SIDE_TAB_CSS)); + let stop = hook::run_stop_hook(&r, &stop_event(&t.path(), "owner")); + assert!(stop.stdout.is_empty()); + assert_eq!(stop.audit["skipped"], "no-build-continuation"); +} + struct Tmp(PathBuf); impl Tmp { fn new() -> Tmp { diff --git a/cursor-plugin/.cursor-plugin/plugin.json b/cursor-plugin/.cursor-plugin/plugin.json index 9ca68a2c1..d15ffe573 100644 --- a/cursor-plugin/.cursor-plugin/plugin.json +++ b/cursor-plugin/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "impeccable", - "version": "4.3.1", + "version": "4.4.0", "description": "Design and refine interfaces with Impeccable skills, specialist agents, and design checks.", "author": { "name": "Renaissance Geek, Inc." diff --git a/cursor-plugin/agents/impeccable-asset-producer.md b/cursor-plugin/agents/impeccable-asset-producer.md index 8f18b63bc..8f833730d 100644 --- a/cursor-plugin/agents/impeccable-asset-producer.md +++ b/cursor-plugin/agents/impeccable-asset-producer.md @@ -18,6 +18,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/cursor-plugin/agents/impeccable-finish-reviewer.md b/cursor-plugin/agents/impeccable-finish-reviewer.md index ee4ec7f8a..669227caf 100644 --- a/cursor-plugin/agents/impeccable-finish-reviewer.md +++ b/cursor-plugin/agents/impeccable-finish-reviewer.md @@ -20,7 +20,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/cursor-plugin/skills/impeccable/SKILL.md b/cursor-plugin/skills/impeccable/SKILL.md index 890ba4653..b77cf282b 100644 --- a/cursor-plugin/skills/impeccable/SKILL.md +++ b/cursor-plugin/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 license: Apache 2.0 --- diff --git a/cursor-plugin/skills/impeccable/reference/component-review.md b/cursor-plugin/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..6ace2c142 --- /dev/null +++ b/cursor-plugin/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `"/scripts/impeccable" component-review capture --manifest .impeccable/review/components.json`, then start `"/scripts/impeccable" component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `"/scripts/impeccable" component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/cursor-plugin/skills/impeccable/reference/degraded/asset-producer.md b/cursor-plugin/skills/impeccable/reference/degraded/asset-producer.md index 1878f0765..56fdd6d91 100644 --- a/cursor-plugin/skills/impeccable/reference/degraded/asset-producer.md +++ b/cursor-plugin/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/cursor-plugin/skills/impeccable/reference/degraded/finish-reviewer.md b/cursor-plugin/skills/impeccable/reference/degraded/finish-reviewer.md index dd37573d2..a00540782 100644 --- a/cursor-plugin/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/cursor-plugin/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/cursor-plugin/skills/impeccable/reference/new-work.md b/cursor-plugin/skills/impeccable/reference/new-work.md index 4c91987fa..a098d3467 100644 --- a/cursor-plugin/skills/impeccable/reference/new-work.md +++ b/cursor-plugin/skills/impeccable/reference/new-work.md @@ -96,7 +96,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`"/scripts/impeccable" build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`"/scripts/impeccable" build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `"/scripts/impeccable" build-phase advance` (every verb below runs as `"/scripts/impeccable" `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -105,7 +105,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -143,3 +146,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `"/scripts/impeccable" build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/cursor-plugin/skills/impeccable/scripts/VERSION b/cursor-plugin/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/cursor-plugin/skills/impeccable/scripts/VERSION +++ b/cursor-plugin/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 698705698..c94206904 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1816,3 +1816,49 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit #### E2E harness contract (`tests/live-e2e.test.mjs`, `tests/live-e2e/*`) - Fake agent polls `GET /poll?token&timeout=5000` (no lease override → 30 s lease), replies via `POST /poll` with `{token,type:'done',sourceEventType:'generate',id,file}`, `steer_done {message,file}`, `error`, accept/discard completions with `data:{carbonize:true,_acceptResult}`/`{_acceptResult}`, manual apply via `live-poll.mjs --reply done --data `. Variant format: 3 variants (font-weights 300/900/600 for render proof), params `lightness` (range), `face` (steps), `italic` (toggle). Scenarios: core, manual, annotations, exit, missed-done, params, mount-failure, republish, storage-loss (fixtures README). Fixture `runtime` block schema is authoritative for what a reimplementation must satisfy end-to-end. + + +## Component review (native, opt-in) + +This development command does not yet replace the skill's build-phase gates. +The packet and evidence contract is documented in +[`ui/component-review/README.md`](../ui/component-review/README.md). + +- `component-review prepare --manifest ` snapshots the + declared comp, components and dependencies into an out-of-project store. + stdout is a JSON object with `session`, `revision`, `round` and `status`: + `awaiting-review`, or the existing receipt's `approved` / `changes-requested`. + Identical input reuses the packet and receipt. Changed input creates a round + and retains only unaffected approvals. +- `component-review capture --manifest ` uses the same + review store, but records native evidence before opening a review round. PNG + sources are decoded and pinned; static HTML/CSS/SVG previews are captured by + isolated Chromium from frozen declared inputs. Missing/failed dependencies, + changing pixels, unsupported scripted previews and mismatched comp dimensions + fail without replacing the current review. Capture outputs live outside the + project; the manifest and source bytes are checked again before submission. + The JSON response also includes `capture` evidence. +- `component-review serve --session [--port ]` runs a foreground + loopback HTTP service. Port defaults to `0` (OS-assigned); stdout reports the + URL and PID. It embeds the shared UI and fonts, serves only pinned declared + files, and persists version-bound browser submissions. It does not open a + browser automatically. +- `component-review status --session ` prints JSON with `revision`, + `receipt`, `capture`, `sourceStatus` (null when current, otherwise an explanation), and + the last recorded `service` metadata. Service metadata is not a liveness check. +- All operations accept `--store `; the default is + `~/.impeccable/component-reviews`. Prepare rejects a store inside the project. + Session IDs are 64 hexadecimal characters. Errors write + `component-review: ` to stderr and return 1; successful non-server + operations return 0. There are no external network or model calls. +- Receipt identity remains `local-browser`. `captureVerified` is false after + plain `prepare`, and true only for native `capture` evidence committed by the + trusted in-process adapter. Producer-written capture claims are discarded. + Capture provenance establishes pixels/source binding, not aesthetic approval, + required HTML semantics, completeness of the intended design, or authenticated + human-eval qualification. Scripted/canvas components need a future adapter; + the command does not silently substitute their script-disabled fallback. + +### Component review verification + +`impeccable component-review verify --manifest ` reads the current native capture and user receipt. It succeeds only for an approved, natively captured round whose manifest and dependency bytes are unchanged. Pending reviews, requested repairs, changed files, and an unrelated manifest sharing the same id fail. It neither creates nor submits approvals. diff --git a/docs/NATIVE-CAPTURE.md b/docs/NATIVE-CAPTURE.md new file mode 100644 index 000000000..cf2a00e08 --- /dev/null +++ b/docs/NATIVE-CAPTURE.md @@ -0,0 +1,43 @@ +# Native static-entry capture and sandbox transport + +Responsive comparison blocks contradicted control regions as well as contradicted text. A passing whole-page score cannot excuse a contradicted declared control. This check still depends on the spec declaring that control separately; it does not establish that the region map is complete or sufficiently granular. + +The opt-in native HTML gate freezes the current entry, allowed static dependencies, spec and approved reference. It captures raster observations in an isolated browser world and verifies the original inputs before accepting them. Saved screenshots and JSON are audit output, not substitutes for capture. The existing build-phase checks retain their copy, geometry, text, presence and fidelity policies. + +Some host sandboxes cannot start Chromium's nested sandbox. `capture-server ` provides the same `CdpEntryRenderer` outside that filesystem sandbox while keeping Chromium's own sandbox enabled. The host chooses the project and exact engine binary before the builder starts. Requests cannot select a different root, URL, shell command, executable or arbitrary skill verb. Normal build-phase execution remains in-process when no transport is configured. + +The service binds loopback and requires the host-generated `IMPECCABLE_CAPTURE_CAPABILITY`; the client reads it together with `IMPECCABLE_CAPTURE_PORT`. It exposes capture, current-input verification, release and saved-evidence audit. Requests, responses, active captures and lifetime are bounded. Captures retain their immutable snapshot; the most recent hero and responsive snapshots survive release for the final host audit. A host adapter must own service startup and process-group cleanup. + +A capability authenticates callers, not arbitrary responses from a builder-selected endpoint. An adapter using this transport must retain its own service process and port, check that process before and after audit requests, and audit through that private reference. The audit compares saved frame PNGs, observation JSON and input reports against host-held capture objects, then rechecks current source bytes. The final responsive snapshot is the current-input boundary; hero legitimately predates later sections. The adapter must verify exported HTML, assets and native evidence against the returned manifest and hashes. A missing or failed host audit invalidates transport provenance. Do not use a client response or a workspace-authored audit file alone as qualification evidence. + +The eval adapter records `nativeCaptureTransport: host-service-v1` as a separate comparison axis. It retains a host audit outside the builder-writable workspace and fails the worker contract on a bad audit. This transport does not introduce a judge, creative instructions, extra repair advice, or a new pass threshold. Other optional browser features, including font-match's documented catalog fallback, retain their existing behavior. + +Raster observations use software rasterization so temporary image suppression restores exact pixels despite GPU tile rounding. Capture-induced CSS transitions finish naturally within a bounded wait; authored styles and motion settings remain intact. DOM, geometry, document identity, response identity and exact pixel restoration still have to match. + +Generated `::before` and `::after` image backgrounds are measured using native CDP pseudo-element boxes. A bounded inspector-owned stylesheet suppresses only the selected URL layers, retaining gradients, other layers and box styling, then clears the override. Computed suppression must actually take effect; an overriding author rule is an unavailable measurement. Each URL remains bound to its captured response bytes. Hidden or covered copies contribute only the pixels they actually paint, and duplicate layers are measured together as well as individually. Unresolved surfaces, content images, canvas and shadow content retain their explicit coverage failures. + + +Final shipping repeats the native responsive comparison against the current HTML, +spec, comp and assets. Review edits therefore cannot inherit an earlier screenshot +pass by recording a new finish hash. A failed final comparison reopens responsive +and records `fix`; the ordinary repair workflow remains available. Legacy capture +runs keep their recorded protocol. + +Native capture disables partial raster reuse as well as GPU rasterization. The +full viewport still must restore byte-identical decoded pixels, DOM, geometry and +network identity. This avoids a reproduced Chromium antialiasing difference after +partial repaints without tolerances or changing the authored page. Receipts record +`partialRaster: false`. Unsupported neighboring SVG/canvas still fail coverage. + +The plate gate also rejects non-texture PNGs carrying `impeccable:crop-of`. +Transforms and a newly embedded prompt do not erase that recorded origin. +Texture patches remain an explicit exception. Absence of the marker is not +proof of generation; evaluation still audits shipped assets against independent +image-generation records. Perceptual similarity remains a separate check. +Caller-supplied metadata, including historical fixture markers, cannot disable +the non-texture comp-copy check. + +After a recorded finish, status uses the shared completion report to distinguish +the unchanged entry from later edits or a missing entry. It does not reopen phases +or sign new bytes. Changed entries are directed back to final review and finish; +dependency validation remains the responsibility of the native capture gate. diff --git a/docs/RELEASE-4.4.0.md b/docs/RELEASE-4.4.0.md new file mode 100644 index 000000000..3f59842ca --- /dev/null +++ b/docs/RELEASE-4.4.0.md @@ -0,0 +1,18 @@ +# Impeccable 4.4.0 release candidate + +Comp-led work now asks the user to review the produced component kit before page assembly, then the assembled first viewport before completion. The shared browser interface covers raster assets and rendered HTML/CSS/SVG, individual approval and repair feedback, missing regions, unchanged approvals across rounds, and current/previous comparison. + +Engine 0.1.6 owns capture, frozen input provenance, durable review sessions and current-input approval verification. Existing comp fidelity, semantic-control and completion checks remain independent; human approval does not manufacture detector evidence or remove an integrity failure. The broader native capture and completion repairs in this PR retain their regression coverage. + +Static component previews are supported in v1. Scripted/canvas components must report an unsupported capture instead of substituting a raster or omitting the component. The local browser service protects against cross-origin submissions; an embedding eval harness separately authenticates its human operator and owns exact provider continuation. + +## Release sequence + +This branch is a candidate, not a published release. Build and test engine 0.1.6 from source for branch evals. Publish the engine and its five platform packages before publishing skill 4.4.0; refresh the lockfile after those optional packages exist. The engine release gate must remain enabled. Copy the final approved release notes into the private site's changelog at release time. This PR does not publish the engine, skill or website. + +## Validation + +- Rust workspace, default Bun/Node suites, native component capture/approval verification, and shared UI state/bundle tests. +- New-work and framework live-mode browser E2E suites run separately for the engine bump. +- Provider-billed cleanup and DeepSeek adapter sweeps require their credentials and must be recorded separately from offline checks. +- Private eval harness qualification is distinct: a waiting component review is pending work, not a successful completed sample or a visual approval. diff --git a/package.json b/package.json index e469433ff..dfba4ab2e 100644 --- a/package.json +++ b/package.json @@ -70,14 +70,15 @@ "release:ext": "node scripts/release.mjs extension", "release:engine": "node scripts/release.mjs engine", "release:platform-packages": "node scripts/publish-platform-packages.mjs", - "check:engine-release": "node scripts/check-engine-release.mjs" + "check:engine-release": "node scripts/check-engine-release.mjs", + "build:component-review": "bun scripts/build-component-review.mjs" }, "optionalDependencies": { - "@impeccable/cli-darwin-arm64": "0.1.5", - "@impeccable/cli-darwin-x64": "0.1.5", - "@impeccable/cli-linux-x64": "0.1.5", - "@impeccable/cli-linux-arm64": "0.1.5", - "@impeccable/cli-windows-x64": "0.1.5" + "@impeccable/cli-darwin-arm64": "0.1.6", + "@impeccable/cli-darwin-x64": "0.1.6", + "@impeccable/cli-linux-x64": "0.1.6", + "@impeccable/cli-linux-arm64": "0.1.6", + "@impeccable/cli-windows-x64": "0.1.6" }, "devDependencies": { "@ai-sdk/anthropic": "^4.0.7", diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index d652d64db..c362e34c0 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "impeccable", "description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", - "version": "4.3.1", + "version": "4.4.0", "author": { "name": "Paul Bakaus", "email": "paul@paulbakaus.com" diff --git a/plugin/.grok-plugin/plugin.json b/plugin/.grok-plugin/plugin.json index 9933f96a4..21e2ebeed 100644 --- a/plugin/.grok-plugin/plugin.json +++ b/plugin/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "impeccable", - "version": "4.3.1", + "version": "4.4.0", "description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", "author": { "name": "Paul Bakaus", diff --git a/plugin/agents/impeccable-asset-producer.md b/plugin/agents/impeccable-asset-producer.md index 929aa5715..d7c22b83c 100644 --- a/plugin/agents/impeccable-asset-producer.md +++ b/plugin/agents/impeccable-asset-producer.md @@ -18,6 +18,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/plugin/agents/impeccable-finish-reviewer.md b/plugin/agents/impeccable-finish-reviewer.md index f83809325..74c372e23 100644 --- a/plugin/agents/impeccable-finish-reviewer.md +++ b/plugin/agents/impeccable-finish-reviewer.md @@ -21,7 +21,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index b2a2e7a5a..7d670ea6e 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -1,5 +1,17 @@ { "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "[ ! -f \"${CLAUDE_PLUGIN_ROOT}/skills/impeccable/scripts/impeccable\" ] || \"${CLAUDE_PLUGIN_ROOT}/skills/impeccable/scripts/impeccable\" hook", + "timeout": 5, + "statusMessage": "Preparing build session" + } + ] + } + ], "PostToolUse": [ { "matcher": "Edit|Write", diff --git a/plugin/skills/impeccable/SKILL.md b/plugin/skills/impeccable/SKILL.md index 4ae57d2a8..0050f742a 100644 --- a/plugin/skills/impeccable/SKILL.md +++ b/plugin/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.3.1 +version: 4.4.0 user-invocable: true argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 diff --git a/plugin/skills/impeccable/reference/component-review.md b/plugin/skills/impeccable/reference/component-review.md new file mode 100644 index 000000000..6ace2c142 --- /dev/null +++ b/plugin/skills/impeccable/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `"/scripts/impeccable" component-review capture --manifest .impeccable/review/components.json`, then start `"/scripts/impeccable" component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `"/scripts/impeccable" component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/plugin/skills/impeccable/reference/degraded/asset-producer.md b/plugin/skills/impeccable/reference/degraded/asset-producer.md index b6f3eda0e..4cfa18149 100644 --- a/plugin/skills/impeccable/reference/degraded/asset-producer.md +++ b/plugin/skills/impeccable/reference/degraded/asset-producer.md @@ -13,6 +13,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/plugin/skills/impeccable/reference/degraded/finish-reviewer.md b/plugin/skills/impeccable/reference/degraded/finish-reviewer.md index b5a131005..af7c451df 100644 --- a/plugin/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/plugin/skills/impeccable/reference/degraded/finish-reviewer.md @@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/plugin/skills/impeccable/reference/new-work.md b/plugin/skills/impeccable/reference/new-work.md index 502af0e67..a7c038801 100644 --- a/plugin/skills/impeccable/reference/new-work.md +++ b/plugin/skills/impeccable/reference/new-work.md @@ -98,7 +98,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`"/scripts/impeccable" build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`"/scripts/impeccable" build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `"/scripts/impeccable" build-phase advance` (every verb below runs as `"/scripts/impeccable" `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -107,7 +107,10 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + +3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. @@ -145,3 +148,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `"/scripts/impeccable" build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/plugin/skills/impeccable/scripts/VERSION b/plugin/skills/impeccable/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/plugin/skills/impeccable/scripts/VERSION +++ b/plugin/skills/impeccable/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/scripts/build-component-review.mjs b/scripts/build-component-review.mjs new file mode 100644 index 000000000..5ff942dcc --- /dev/null +++ b/scripts/build-component-review.mjs @@ -0,0 +1,7 @@ +// Source bundle embedded in the native engine; no Node/Bun is needed at runtime. +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +const root = fileURLToPath(new URL('../', import.meta.url)); +const result = await Bun.build({ entrypoints: [resolve(root, 'ui/component-review/entry.ts')], target: 'browser', format: 'iife', minify: true }); +if (!result.success) throw new AggregateError(result.logs, 'Component review bundle failed'); +await Bun.write(resolve(root, 'crates/context/assets/component-review.js'), await result.outputs[0].text()); diff --git a/scripts/lib/transformers/hooks.js b/scripts/lib/transformers/hooks.js index ab48e419d..7240afe09 100644 --- a/scripts/lib/transformers/hooks.js +++ b/scripts/lib/transformers/hooks.js @@ -94,10 +94,12 @@ const GROK_PROJECT_SCRIPTS = '.grok/skills/impeccable/scripts'; // `windows: true` adds the `commandWindows` sibling; only Codex-shaped // consumers honor it, and an unknown key would fail Codex's strict parser if // it were the other way round, so it stays opt-in per manifest. -function buildClaudeCompatibleHooks(matcher, scriptsDir, { windows = false } = {}) { +function buildClaudeCompatibleHooks(matcher, scriptsDir, { windows = false, sessionIdentity = false } = {}) { const command = guardedLauncher(launcherIn(scriptsDir)); const commandWindows = windows ? windowsLauncherCommand(launcherCmdIn(scriptsDir)) : undefined; return { + ...(sessionIdentity ? { SessionStart: [{ hooks: [{ type: 'command', command, + timeout: TIMEOUT_SECONDS, statusMessage: 'Preparing build session' }] }] } : {}), PostToolUse: [ { matcher, @@ -119,7 +121,7 @@ function buildClaudeCompatibleHooks(matcher, scriptsDir, { windows = false } = { export function buildClaudeSettingsManifest() { return { description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.', - hooks: buildClaudeCompatibleHooks('Edit|Write', CLAUDE_PROJECT_SCRIPTS), + hooks: buildClaudeCompatibleHooks('Edit|Write', CLAUDE_PROJECT_SCRIPTS, { sessionIdentity: true }), }; } @@ -131,7 +133,7 @@ export function buildClaudeSettingsManifest() { // than `hooks`, failing the whole manifest (issue #330). export function buildClaudePluginHooksManifest() { return { - hooks: buildClaudeCompatibleHooks('Edit|Write', CLAUDE_PLUGIN_SCRIPTS), + hooks: buildClaudeCompatibleHooks('Edit|Write', CLAUDE_PLUGIN_SCRIPTS, { sessionIdentity: true }), }; } @@ -205,6 +207,20 @@ export function buildGrokHooksManifest() { }; } +// Gemini's hook timeouts are milliseconds. BeforeTool transports only the +// session environment; AfterAgent uses the engine's shared completion check. +export function buildGeminiHooksManifest() { + const command = guardedLauncher('$GEMINI_PROJECT_DIR/.gemini/skills/impeccable/scripts/impeccable'); + return { hooks: { + BeforeTool: [{ matcher: '^run_shell_command$', hooks: [{ + name: 'impeccable-session', type: 'command', command, timeout: 5000, + }] }], + AfterAgent: [{ hooks: [{ + name: 'impeccable-completion', type: 'command', command, timeout: 30000, + }] }], + } }; +} + export function hooksJsonFor(provider, options = {}) { switch (provider) { case 'claude': @@ -217,6 +233,8 @@ export function hooksJsonFor(provider, options = {}) { return buildGitHubHooksManifest(); case 'grok': return buildGrokHooksManifest(); + case 'gemini': + return buildGeminiHooksManifest(); default: return null; } diff --git a/scripts/lib/transformers/providers.js b/scripts/lib/transformers/providers.js index 37b960892..e712d884e 100644 --- a/scripts/lib/transformers/providers.js +++ b/scripts/lib/transformers/providers.js @@ -43,6 +43,8 @@ export const PROVIDERS = { configDir: '.gemini', displayName: 'Gemini', frontmatterFields: [], + emitHooks: 'gemini', + hooksManifestRel: 'settings.json', }, dsh: { provider: 'dsh', diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index fa0de35af..6760d296a 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -29,6 +29,8 @@ export const SUITES = { core: { description: 'Build, provider transforms, hook manifests, plugin validators, and prose gates.', triggers: [ + /^ui\/component-review\//, + /^crates\/context\/assets\/component-review\.js$/, ...COMMON_INFRA_PATTERNS, /^scripts\/(?!build-extension)/, /^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/)/, @@ -43,6 +45,9 @@ export const SUITES = { runner: 'bun', files: [ 'tests/build.test.js', + 'tests/component-review-bundle.test.js', + 'ui/component-review/model.test.ts', + 'ui/component-review/viewport.test.ts', 'tests/lib/provider-blocks.test.js', 'tests/lib/transformers/provider-blocks.test.js', 'tests/lib/utils.test.js', diff --git a/skill/agents/impeccable-asset-producer.md b/skill/agents/impeccable-asset-producer.md index 9df84f1c2..dbf9fc11a 100644 --- a/skill/agents/impeccable-asset-producer.md +++ b/skill/agents/impeccable-asset-producer.md @@ -24,6 +24,10 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run. +## Review handoff + +After the initial production batch, return the actual files and any unresolved drift to the parent for the user-facing component review in [component-review.md](../reference/component-review.md). The parent includes rendered code regions alongside these rasters. A parent or automatic visual check is not a substitute for that human checkpoint. Apply requested repairs and preserve unchanged assets; do not self-approve them. This checkpoint does not apply to the Decision Comps job above. + ## Input Contract Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. diff --git a/skill/agents/impeccable-finish-reviewer.md b/skill/agents/impeccable-finish-reviewer.md index 1be3b6dff..e7bfd6638 100644 --- a/skill/agents/impeccable-finish-reviewer.md +++ b/skill/agents/impeccable-finish-reviewer.md @@ -27,7 +27,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and every phase before `review` is `closed` or explicitly `skipped`; an open or failed phase is a material finding. A comp-led config with no state file, or a state whose `comps` phase is neither `closed` nor `skipped`, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. 2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. diff --git a/skill/reference/component-review.md b/skill/reference/component-review.md new file mode 100644 index 000000000..51db786ec --- /dev/null +++ b/skill/reference/component-review.md @@ -0,0 +1,57 @@ +# Component review + +Use this checkpoint on comp-led builds after producing the initial component kit and before composing the page. The approved comp is the reference. The user reviews the actual produced components, including code; a list of planned assets or screenshots supplied by the builder is not a review of what will ship. + +## Prepare the component kit + +Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. + +Write `.impeccable/review/components.json` with this manifest format: + +```json +{ + "schemaVersion": 1, + "id": "components", + "title": "Component review", + "stage": "components", + "comp": {"path": ".impeccable/mocks/comp-2.png", "width": 1536, "height": 1024}, + "components": [ + { + "id": "illustration", + "name": "Illustration", + "medium": "raster", + "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, + "note": "Produced cutout; positioned over the page ground.", + "preview": {"kind": "image", "path": "assets/illustration.png"}, + "dependencies": [".impeccable/build/spec.json"] + }, + { + "id": "headline", + "name": "Headline", + "medium": "html", + "box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25}, + "note": "Rendered semantic heading and its typography.", + "preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"}, + "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + } + ] +} +``` + +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. Local paths only. PNG previews preserve their actual transparency; never draw a checkerboard into the asset. + +Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it. + +## Present and wait + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures the component files, presents this same review interface and returns the user's decisions. A suspended request is waiting for the user; it is not a failed build or an approval. + +Otherwise run `{{scripts_path}}/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `{{scripts_path}}/impeccable component-review serve --session ` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `{{scripts_path}}/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf. + +The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved. + +## Assemble and review + +Build the page from the approved component files. Replacing, simplifying or changing an approved component requires a new component review. Run the existing plates and hero gates; human review does not waive their integrity checks. + +After the full page and responsive checks are complete, present a second manifest at `.impeccable/review/hero.json`, with `id` and `stage` set to `hero`. Use one page-preview component covering the assembled first viewport, its real HTML entry, and its complete dependency list. The reference stays the approved comp. Call the same host review tool (or native capture/serve/verify workflow) and obtain the user's approval before the final response. Later edits to the reviewed files require a fresh review. A component-kit approval does not approve their assembled layout. diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index 40e1fbbda..41c0aa451 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -100,7 +100,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`{{scripts_path}}/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`{{scripts_path}}/impeccable build-phase start --direction --kind --artifact ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp --artifact ` when a surface round already locked one. Then, in order, each closed by `{{scripts_path}}/impeccable build-phase advance` (every verb below runs as `{{scripts_path}}/impeccable `; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): @@ -109,6 +109,9 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. + +Before assembling the page, complete [component-review.md](component-review.md): present the full produced component kit, including rendered code regions, and obtain the user's approval. After the page and responsive checks are complete, use its assembled-hero checkpoint before the final response. Keep the existing phase gates. + 3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. @@ -147,3 +150,5 @@ A rebuild and a fix round share one asset rule: a raster either round creates or Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. After the last correction, spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, artifact path, direction contract, PRODUCT.md, [document.md](document.md), and write boundary. Without subagents, load [degraded/documenter.md](degraded/documenter.md) and [document.md](document.md) before writing. Verify the outcome: new worlds and approved system changes require token-bearing DESIGN.md **and** `.impeccable/design.json`, not prose alone. Ordinary extensions compare the finished build against the incumbent system, preserve its files, and report the evidence checked; report pre-existing drift without repairing it unasked. Recheck after later edits. Finish only when review and documentation are complete. + +On a comp-led build, record the final review disposition with `{{scripts_path}}/impeccable build-phase finish --disposition ` before the final response. A refused `ship` is an unfinished build; report the outstanding phase with the verdict. diff --git a/skill/scripts/VERSION b/skill/scripts/VERSION index 9faa1b7a7..c946ee616 100644 --- a/skill/scripts/VERSION +++ b/skill/scripts/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/tests/component-review-bundle.test.js b/tests/component-review-bundle.test.js new file mode 100644 index 000000000..0945637d1 --- /dev/null +++ b/tests/component-review-bundle.test.js @@ -0,0 +1,8 @@ +import { test, expect } from 'bun:test'; +import { resolve } from 'node:path'; +const root=resolve(import.meta.dir,'..'); +test('native component review bundle matches the shared UI source',async()=>{ + const result=await Bun.build({entrypoints:[resolve(root,'ui/component-review/entry.ts')],target:'browser',format:'iife',minify:true}); + expect(result.success).toBe(true); + expect(await result.outputs[0].text()).toBe(await Bun.file(resolve(root,'crates/context/assets/component-review.js')).text()); +}); diff --git a/tests/hook-build.test.mjs b/tests/hook-build.test.mjs index 70a85bdc3..436a7b32c 100644 --- a/tests/hook-build.test.mjs +++ b/tests/hook-build.test.mjs @@ -17,6 +17,7 @@ import { buildCursorHooksManifest, buildGitHubHooksManifest, buildGrokHooksManifest, + buildGeminiHooksManifest, hooksJsonFor, } from '../scripts/lib/transformers/hooks.js'; @@ -66,6 +67,15 @@ function manifestCommands(manifest) { } describe('hook manifest builders', () => { + it('builds Gemini native session and completion hooks with millisecond timeouts', () => { + const manifest = buildGeminiHooksManifest(); + assert.deepEqual(Object.keys(manifest.hooks), ['BeforeTool', 'AfterAgent']); + assert.equal(manifest.hooks.BeforeTool[0].matcher, '^run_shell_command$'); + assert.equal(manifest.hooks.BeforeTool[0].hooks[0].timeout, 5000); + assert.equal(manifest.hooks.AfterAgent[0].hooks[0].timeout, 30000); + expectCommand(manifest.hooks.AfterAgent[0].hooks[0].command, '.gemini/skills/impeccable/scripts'); + assert.match(manifest.hooks.AfterAgent[0].hooks[0].command, /\$GEMINI_PROJECT_DIR/); + }); it('builds Claude project settings for the real detector hook', () => { const manifest = buildClaudeSettingsManifest(); const group = manifest.hooks.PostToolUse[0]; @@ -79,7 +89,8 @@ describe('hook manifest builders', () => { expectCommand(handler.command, '.claude/skills/impeccable/scripts'); assert.ok(handler.command.includes('${CLAUDE_PROJECT_DIR}')); assert.equal(handler.args, undefined); - assert.equal(manifest.hooks.SessionStart, undefined); + expectCommand(manifest.hooks.SessionStart[0].hooks[0].command, '.claude/skills/impeccable/scripts'); + assert.equal(manifest.hooks.SessionStart[0].hooks[0].timeout, 5); // Stop deep pass: same script, no matcher, longer budget. const stop = manifest.hooks.Stop[0].hooks[0]; @@ -245,7 +256,8 @@ describe('hook manifest builders', () => { assert.ok(hooksJsonFor('cursor')); assert.ok(hooksJsonFor('github')); assert.ok(hooksJsonFor('grok')); - assert.equal(hooksJsonFor('gemini'), null); + assert.ok(hooksJsonFor('gemini')); + assert.equal(hooksJsonFor('unrecognized'), null); }); }); diff --git a/tests/oracle/cases/comp.mjs b/tests/oracle/cases/comp.mjs index 9a1f24671..e2660ccb1 100644 --- a/tests/oracle/cases/comp.mjs +++ b/tests/oracle/cases/comp.mjs @@ -52,6 +52,14 @@ const cases = [ { id: 'comp-spec-print', verb: 'comp-spec', workspace: WS, args: ['--print', '--spec', 'spec.json'], env: env() }, { id: 'comp-spec-plate-prompt', verb: 'comp-spec', workspace: WS, args: ['--plate-prompt', 'art', '--spec', 'spec.json'], env: env() }, { id: 'comp-spec-usage', verb: 'comp-spec', workspace: WS, args: [], env: env() }, + { + id: 'comp-spec-excluded-reference', verb: 'comp-spec', workspace: WS, + setup: (ws) => write(ws, 'excluded.json', JSON.stringify({ comp: 'comp.png', regions: [ + { id: 'art', kind: 'plate', medium: 'raster', px: { x: 0, y: 0, w: 32, h: 32 } }, + { id: 'nav', kind: 'chrome', px: { x: 0, y: 0, w: 32, h: 32 } }, + ] })), + args: ['--crop', 'art', '--spec', 'excluded.json', '--out', 'excluded.png'], env: env(), + }, { id: 'comp-spec-refuses-painted-chrome', verb: 'comp-spec', workspace: WS, setup: (ws) => write(ws, 'bad.json', JSON.stringify({ allowUncovered: true, regions: [{ id: 'x', kind: 'chrome', grid: 'A0:B1', note: 'an exploded diagram illustration' }] })), @@ -74,7 +82,7 @@ const cases = [ // build-phase: start (reads comp dims) then status, sharing one workspace. { id: 'build-phase-start-status', verb: 'build-phase', workspace: WS, - files: ['.impeccable/build/state.json'], env: env(), + files: ['.impeccable/build/state.json'], env: { ...env(), IMPECCABLE_SESSION_ID: 'oracle-build' }, steps: [{ args: ['start', '--comp', 'comp.png'] }, { args: ['status'] }], }, { id: 'build-phase-usage', verb: 'build-phase', workspace: WS, args: [], env: env() }, diff --git a/tests/oracle/golden/build-phase-start-status.json b/tests/oracle/golden/build-phase-start-status.json index 141062c3b..e3e380903 100644 --- a/tests/oracle/golden/build-phase-start-status.json +++ b/tests/oracle/golden/build-phase-start-status.json @@ -14,6 +14,6 @@ } ], "files": { - ".impeccable/build/state.json": "{\n \"tool\": \"build-phase\",\n \"version\": 2,\n \"startedAt\": \"\",\n \"comp\": \"comp.png\",\n \"direction\": null,\n \"breakpoint\": \"768x512\",\n \"artifact\": null,\n \"phase\": \"spec\",\n \"phases\": {\n \"comps\": {\n \"status\": \"skipped\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [\n {\n \"at\": \"\",\n \"text\": \"started with an approved comp; the comp round happened before this state (surface round or manual)\"\n }\n ],\n \"gate\": null,\n \"forced\": null\n },\n \"spec\": {\n \"status\": \"open\",\n \"openedAt\": \"\",\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"plates\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"hero\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"sections\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"motion\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"responsive\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"review\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n }\n },\n \"finish\": null\n}" + ".impeccable/build/state.json": "{\n \"tool\": \"build-phase\",\n \"version\": 2,\n \"startedAt\": \"\",\n \"comp\": \"comp.png\",\n \"direction\": null,\n \"breakpoint\": \"768x512\",\n \"artifact\": null,\n \"phase\": \"spec\",\n \"phases\": {\n \"comps\": {\n \"status\": \"skipped\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [\n {\n \"at\": \"\",\n \"text\": \"started with an approved comp; the comp round happened before this state (surface round or manual)\"\n }\n ],\n \"gate\": null,\n \"forced\": null\n },\n \"spec\": {\n \"status\": \"open\",\n \"openedAt\": \"\",\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"plates\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"hero\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"sections\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"motion\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"responsive\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"review\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n }\n },\n \"finish\": null,\n \"sessionId\": \"oracle-build\"\n}" } } diff --git a/tests/oracle/golden/build-phase-usage.json b/tests/oracle/golden/build-phase-usage.json index d923b6031..58a820d70 100644 --- a/tests/oracle/golden/build-phase-usage.json +++ b/tests/oracle/golden/build-phase-usage.json @@ -1,6 +1,6 @@ { "stdout": "", - "stderr": "usage: build-phase.mjs start --comp [--breakpoint WxH] | status [--json] | advance [--force --reason \"...\"] | record hero --build | scaffold | note \"\" | finish --disposition \n", + "stderr": "usage: build-phase.mjs start --comp [--breakpoint WxH] [--artifact ] [--session-id ] | status [--json] | completion [--session-id ] | advance [--force --reason \"...\"] | record hero --build | scaffold | note \"\" | finish --disposition \n", "exit": 1, "signal": null, "files": {} diff --git a/tests/oracle/golden/comp-spec-excluded-reference.json b/tests/oracle/golden/comp-spec-excluded-reference.json new file mode 100644 index 000000000..ff4fcc734 --- /dev/null +++ b/tests/oracle/golden/comp-spec-excluded-reference.json @@ -0,0 +1,7 @@ +{ + "stdout": "", + "stderr": "comp-spec: reference for art has no visible pixels after excluding nav; correct the overlapping region geometry or container roles in the comp spec before evaluating this asset.\n", + "exit": 2, + "signal": null, + "files": {} +} diff --git a/tests/plugin-e2e.test.mjs b/tests/plugin-e2e.test.mjs index e614cce12..457283ca7 100644 --- a/tests/plugin-e2e.test.mjs +++ b/tests/plugin-e2e.test.mjs @@ -144,7 +144,8 @@ describe('committed plugin subtree loads in a real Claude Code', { skip }, () => }); it('discovers the packaged hooks', () => { - assert.equal(componentCount('Hooks'), 2); + assert.equal(componentCount('Hooks'), 3); + assert.match(detailsOutput, /SessionStart/); assert.match(detailsOutput, /PostToolUse/); assert.match(detailsOutput, /Stop/); }); diff --git a/ui/component-review/README.md b/ui/component-review/README.md new file mode 100644 index 000000000..affef8b70 --- /dev/null +++ b/ui/component-review/README.md @@ -0,0 +1,106 @@ +# Shared component review — UI checkpoint + +This framework-independent browser surface is owned by Impeccable. The eval dashboard imports it directly for the current visual checkpoint; the native `component-review` command serves the same bundle. The comp-led skill workflow presents it after the initial component kit and again after assembling the page. Existing build-phase integrity gates remain independent. + +`ReviewPacket` contains a request/revision identity, the pinned comp dimensions, and the component inventory. Raster and sandboxed page-region previews share the same comparison canvas. `Draft` records individual decisions, optional split requests, missing regions and explicit inventory completeness. `onSubmit` belongs to the trusted host adapter. A UI draft is not an authenticated human approval. + +The eval preview uses actual historical hotel artifacts and manually mapped titles. Its HTML views clip the existing page at the recorded comp-region bounds. These are interface test data, not proof that an initial component-production round occurred. No historical approvals are inferred, rewritten or reused. No model calls occur. + +Implemented runtime foundation: manifest/path validation, pinned dependency bytes, version-bound local-browser feedback stored outside the builder project, and scoped approval invalidation. Native static component capture is implemented below. The eval harness owns authenticated reviewer routing, provider pause/resume and its attention queue; these are not responsibilities of this public UI. The trusted adapter must reject malformed, duplicate or stale submissions and verify the reviewer identity. No permissions decision is based on a browser-provided actor name. + +The dashboard route `/dashboard/component-review/` is a temporary review checkpoint. Final embedding belongs in the waiting run's detail view. Keep the launch hold until the runtime and restart/resume checks pass. + +## Native runtime + +Build the shared UI with `bun run build:component-review`, then rebuild the engine. It embeds the JS and licensed fonts; the runtime needs no Node server or dashboard. The bundled asset is tracked alongside the native consumer, and the bundle test catches source drift. + +From a project, run `impeccable component-review prepare --manifest review.json`. The manifest uses the UI packet shape plus `schemaVersion: 1`; replace each preview/comp/context/thumbnail `url` with a project-relative `path`, and declare each component's `dependencies` (CSS, images, fonts and other inputs used by its rendered preview). The service assigns revisions and round numbers. Paths outside the project are refused. Prepare prints a session ID. Start `impeccable component-review serve --session ` and open the returned loopback URL. `status --session ` reads the saved result. The default store is `~/.impeccable/component-reviews`; a test may use `--store `. + +Feedback commits atomically and identically retried submissions are idempotent. A second, different response to the same round is refused. Preparing changed inputs creates a new round, preserves unaffected approvals, resets inventory completeness, and carries missing-region feedback forward for human resolution. Prior packets remain addressable by revision; their URLs never silently switch to a new round's pixels. Closing/restarting the server preserves the packet and submitted feedback. Unsubmitted typing is not yet autosaved. + +### Evidence boundary + +Plain `prepare` pins the producer's declared inputs and leaves `captureVerified: false`. Native `capture` renders static code using only frozen declared inputs, verifies observed browser response bytes and stable pixels, and records the resulting captures outside the project. Only this in-process adapter can produce `captureVerified: true`. Both paths retain `reviewer: local-browser`; no browser-provided actor name grants human-eval qualification. The out-of-project store prevents a builder workspace JSON edit from changing authoritative feedback. Host/Origin/fetch-metadata checks reject cross-site browser requests, but do not authenticate a person against another process with the same OS permissions. Eval integration must own that trust boundary and preserve the distinction in provenance. + +Generated HTML/SVG is served with a restrictive CSP and sandbox: no scripts, network requests, form submissions, or access to approval controls. Fonts can load from pinned files in an opaque-origin frame. Script-dependent components require a trusted captured preview; they are not yet supported as live executable component frames. Absolute-root asset URLs are not rewritten; the packet must use self-contained relative dependencies. + +The runtime currently serves standalone with `frame-ancestors 'none'`. Transcript embedding must add an explicit trusted parent/broker rather than weakening this globally. It never opens Chrome automatically, starts an eval, changes existing campaign data, or bypasses fidelity/provenance gates. + + +## Repair rounds + +The host supplies `ReviewHistory` separately from the producer's packet. The +native store derives it from saved packets and decisions: prior preview URLs, +component changes, outstanding feedback with its originating round, carried +approvals, and removed inventory entries. History is not inferred from filenames +or accepted from a producer-authored manifest field. + +The round summary leads to changed/new components. Current/Previous switches +show each round's own comp and preview at its recorded geometry. Previous-round +inspection disables both individual and batch approval. Change details explain +whether files, region geometry or component definition invalidated an approval. +The inspector remains a fixed height and retains scroll position on view toggles. + +Content identity and review identity are distinct. An unchanged prepare is a +no-op; reverting to earlier content creates a fresh review round whose identity +includes its predecessor. An old submission cannot approve that new round, and +its archived receipt is not overwritten. Preparing several versions before a +reply preserves outstanding feedback; a later explicit approval resolves it. + +The native hotel checkpoint is an isolated integration-test copy. Its second +round changes only the boat description, explicitly labelled as such, to test +feedback and approval carryover without altering artwork or historical evals. + + +Feedback hierarchy: prior requests appear in a read-only **Previous feedback** +section with their originating round. The **Preview** control affects only the +comparison images. The separate **Your review** section names the current round; +choosing Needs work opens an empty **New feedback** field. Prior text is never +silently copied into that field. Definition-only changes explicitly state that +files are unchanged and expose the old/current descriptions. + + +## Attention-first navigation + +Map markers and inventory cards use one revision-aware status function. Gold +numbered circles need review; subdued checked markers are approved; a separate +pencil state indicates feedback ready to send. Shape/icon, text and color carry +the distinction together. Stable component numbers do not change with sorting +or filtering. Missing marks also appear on the comp map. + +The inventory defaults to Needs attention, excluding approved components and +sorting changed/new components first. Approved and All remain available. A +filter change selects a matching component when necessary. Next to review skips +already decided items; choosing Needs work retains that component in the +attention list while the reviewer writes feedback. Stale approval revisions +never hide a component. Bulk approval still preserves repair requests, and +inventory confirmation is independently required before approval submission. + + +## Native static component capture + +Use `impeccable component-review capture --manifest review.json` instead of +`prepare` when the component packet needs native provenance. Both use the same +versioned store, service and decision contract. PNG raster previews remain their +actual files (including alpha). Page previews become native PNG crops captured +at the comp's declared viewport and component box. Context views, when supplied, +are captured too; thumbnails reuse the primary output. Supply isolated component +HTML for the first asset round, not a screenshot pretending to be HTML. + +`crates/browser/html_snapshot` and the CDP response/isolated-world primitives +are extracted from the existing native capture candidate. They freeze and serve +explicit input bytes; the new component adapter adds no aesthetic scoring or +comp-fidelity exception. It waits for images/fonts, verifies the document and +observed resource bodies against the frozen inputs, and checks that the pixels, +DOM and network stay stable across capture. The manifest itself is bound too. +A dependency or region change makes pending submission stale. Capture failure +never replaces an existing review round or substitutes supplied screenshots. + +V1 supports PNG files and static HTML/CSS/inline SVG documents. Scripted, canvas, +framed and actively animated components are rejected instead of accepting their +fallback. CSS reduced-motion behavior is respected through the browser preference; +styles are not rewritten to make a capture pass. This is provenance for the +rendered state, not a claim that all future interactive states are covered. + +The adapter records observed semantic-control, SVG and raster counts as evidence; +it does not decide that required semantics or visual fidelity pass. Human identity, run routing and exact provider continuation belong to the consuming harness. The hero stage uses the same manifest and native capture contract. diff --git a/ui/component-review/app-layout.ts b/ui/component-review/app-layout.ts new file mode 100644 index 000000000..bdc7a88d3 --- /dev/null +++ b/ui/component-review/app-layout.ts @@ -0,0 +1,58 @@ +/** Viewport shell: fixed surfaces frame independently scrolling inspection and component panes. */ +export const appLayout = ` +:host{height:var(--component-review-height,100dvh);min-height:0;overflow:hidden} +.review{height:100%;max-width:none;min-height:0;padding:0;display:flex;flex-direction:column;overflow:hidden;background:var(--color-bg,#fafafa)} +.review>header{flex-shrink:0;padding:16px 24px;margin:0;border-bottom:1px solid var(--line);gap:16px}.review>header>div{min-width:0}.review h1{font-size:30px;line-height:1}.review>header p{font-size:12px;margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.review .badge{font-size:11px} +.review>.preview-note{flex-shrink:0;margin:0;padding:8px 24px;font-size:11px}.review>.round-summary{flex-shrink:0;margin:0;padding:8px 24px;border-top:0;gap:6px 16px;max-height:120px;overflow:auto}.round-summary p{font-size:12px;gap:6px 14px}.round-summary button{font-size:12px;min-height:32px;padding:5px 10px} +.review>.workbench{flex:1;min-height:0;align-items:stretch;padding:20px 24px;gap:32px;overflow:hidden;grid-template-columns:minmax(0,1.12fr) minmax(0,1fr)}.reference{display:flex;flex-direction:column;min-height:0;width:100%;max-width:none;margin:0}.reference>.section-head{flex-shrink:0;margin-bottom:10px}.map-space{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;background:#eeefec;border:1px solid var(--line);overflow:hidden}.map{flex-shrink:0;max-width:100%;max-height:100%}.map-legend{flex-shrink:0;margin-top:9px;gap:6px 14px}.map-caption{flex-shrink:0;min-height:0;padding-top:6px;font-size:10px}.map-caption button{display:inline-block;margin:0 0 0 8px;min-height:28px;padding:3px 7px} +.workbench>.inspector{height:100%;min-height:0;padding:0;border:0}.inspector>.section-head{margin-bottom:10px;min-height:32px}.section-head h2{font-size:16px}.inspection-content{padding-bottom:8px}.review-form{max-height:55%;overflow-y:auto;scrollbar-width:thin;scrollbar-gutter:stable}.material{min-height:28px}.material strong{font-size:13px}.compare-toolbar{margin-bottom:12px}.previous-feedback{padding:10px 12px}.repair-context{margin-bottom:12px}.preview-round{margin-bottom:10px}.decision-title p{font-size:11px}.decisions>button{min-height:40px;font-size:13px}.decision-title strong{font-size:13px}.view-controls{margin-top:8px}.component-details{height:auto;max-height:80px} +.review>.inventory-section{height:236px;flex-shrink:0;display:flex;flex-direction:column;min-height:0;margin:0;padding:10px 24px 8px;border-top:1px solid var(--line);background:var(--color-panel,#f5f5f2);overflow:hidden}.inventory-section>.section-head{margin:0 0 8px;min-height:36px;flex-shrink:0;gap:10px}.inventory-section h2{font-size:14px}.inventory{flex:1;min-height:0;align-items:stretch;margin:0;padding:3px 3px 7px;overflow-x:auto;overflow-y:hidden}.inventory.all{overflow:auto;align-items:start;grid-auto-rows:160px}.inventory .item{flex:0 0 136px;padding:8px;gap:3px;grid-template-rows:auto auto minmax(24px,1fr) auto;min-height:0}.inventory .item-thumb{height:46px;margin-bottom:3px}.inventory .thumb-crop{max-height:46px}.inventory .item-number{font-size:11px;min-height:16px;padding:0}.inventory .item strong{font-size:12px;min-height:26px;line-height:1.2}.inventory .state{font-size:11px;padding-top:4px}.tray-actions{display:flex;gap:6px;align-items:center}.tray-actions button{white-space:nowrap;font-size:11px;min-height:32px}.tray-actions #toggle-tray{display:flex;align-items:center;gap:6px}.tray-actions svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}.review>.inventory-section.tray-expanded{height:min(42dvh,390px)}.review>.inventory-section.tray-collapsed{height:56px;padding-block:10px}.tray-collapsed .inventory{display:none}.tray-collapsed>.section-head{margin-bottom:0} +.review>footer{flex-shrink:0;margin:0;padding:12px 24px;gap:16px;border-top:1px solid var(--line);background:var(--paper);align-items:center}.review>footer>div:first-child{display:flex;align-items:center;gap:14px;min-width:0}.review>footer .check{margin:0;max-width:240px;font-size:11px}.review>footer #approve-rest{min-height:36px;font-size:12px;max-width:210px;padding:7px 10px}.review>footer .submit-area{gap:12px}.review>footer .submit-area p{font-size:11px;max-width:24ch}.review>footer .primary{min-height:40px;font-size:13px}.mobile-panes{display:none} +/* Depth describes the shell: recessed work area, raised inspector, anchored docks. */ +.review{--workspace:#e3e6e2;--canvas:#d5dad5;--dock:#f6f7f4;--surface:#fff;background:var(--workspace)} +.review>header{position:relative;z-index:8;background:var(--surface);border-bottom-color:#d5d9d3} +.review>.round-summary,.review>.preview-note{position:relative;z-index:7;background:var(--dock);box-shadow:0 3px 6px #202b2510;border-bottom-color:#c8cfc7} +.review>.workbench{background:var(--workspace)} +.map-space{background:var(--canvas);border-color:#bdc6bd;box-shadow:inset 0 2px 7px #263d2a12;border-radius:5px} +.map{box-shadow:0 3px 10px #182a242b} +.workbench>.inspector{background:var(--surface);border-radius:6px;box-shadow:0 2px 4px #24332812,0 8px 24px #24332818;scrollbar-gutter:auto;isolation:isolate} +.inspector>.section-head{position:relative;z-index:2;margin:0;padding:12px 16px;background:var(--surface);border-bottom:1px solid #dce0da} +.inspection-content{padding:14px 12px 14px 16px;background:#fafbf9} +.inspector>.review-form{position:relative;z-index:2;padding:12px 12px 12px 16px;background:var(--surface);border-top:1px solid #cdd4ca} +.inspector[data-scroll-above=true]>.section-head{box-shadow:0 6px 8px -4px #24332838} +.inspector[data-scroll-below=true]>.review-form{box-shadow:0 -6px 8px -4px #24332838} +.inspection-content,.review-form,.inventory{scrollbar-width:auto;scrollbar-color:#8d9c91 #e3e8e1;overscroll-behavior:contain} +.inspection-content::-webkit-scrollbar,.review-form::-webkit-scrollbar,.inventory::-webkit-scrollbar{width:10px;height:10px} +.inspection-content::-webkit-scrollbar-track,.review-form::-webkit-scrollbar-track,.inventory::-webkit-scrollbar-track{background:#e3e8e1;border-radius:6px} +.inspection-content::-webkit-scrollbar-thumb,.review-form::-webkit-scrollbar-thumb,.inventory::-webkit-scrollbar-thumb{background:#8d9c91;border:2px solid #e3e8e1;border-radius:6px} +.review>.inventory-section{position:relative;z-index:8;background:var(--dock);border-top-color:#b9c3b8;box-shadow:0 -3px 5px #2433280c,0 -10px 24px #24332814} +.inventory-section>.section-head{border-bottom:1px solid #d9dfd5;padding-bottom:8px;margin-bottom:8px} +.tray-collapsed>.section-head{border:0;padding-bottom:0;margin-bottom:0} +.inventory{background:#e9ece5;border-radius:4px;padding:7px 7px 9px} +.inventory .item{background:#fafbf8} +.inventory .item.active{background:#fff} +.review>footer{position:relative;z-index:9;background:var(--surface);border-top-color:#ccd4c7;box-shadow:0 -2px 5px #2433280b} +.mobile-panes{position:relative;z-index:7;box-shadow:0 3px 6px #202b2510} +/* Utility actions share a compact icon language; decisions retain explicit labels. */ +.utility-icon{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0} +.review .icon-button{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:36px;height:36px;min-height:36px;padding:7px;text-decoration:none;border:1px solid transparent;border-radius:4px;background:transparent;color:var(--muted)} +.review .icon-button:hover{background:#e5ebe4;border-color:#b9c8bd;color:var(--teal)} +.review .icon-button[aria-pressed=true]{background:#e5eee8;color:var(--teal)} +.review .icon-button:focus-visible{outline:2px solid var(--teal);outline-offset:2px} +.review .label-icon{display:inline-flex;align-items:center;justify-content:center;gap:7px} +.review .tray-actions .utility-icon{width:18px;height:18px;stroke-width:1.6} +.material{align-items:center;margin-bottom:10px}.material .source-link{margin-left:auto;width:30px;height:30px;min-height:30px;padding:5px} +.preview-round{justify-content:flex-end;margin-bottom:10px} +.compare-toolbar{margin-bottom:10px} +.view-controls{min-height:0}.view-controls>.background-options{margin-left:auto;gap:2px}.view-controls .swatch-button{display:flex;align-items:center;justify-content:center;min-width:32px;height:32px;padding:5px} +.background-swatch{display:block;width:20px;height:20px;border:1px solid #9ba99d;border-radius:2px;pointer-events:none}.background-swatch.checker{background-size:8px 8px}.page-swatch{background:var(--comp-background,#eee)} +.review>footer .submit-area p{max-width:25ch} +@media(max-width:1100px) and (min-width:801px){.review>header{padding:12px 20px}.review>.workbench{padding:16px 20px;gap:24px}.review>.round-summary{padding-inline:20px}.round-summary p{max-width:calc(100% - 52px)}.round-summary p>span{font-size:11px}.inventory-section>.section-head h2{max-width:none;white-space:nowrap}.review>footer>div:first-child{gap:8px}.review>footer .submit-area p{max-width:19ch}.inventory-filters button{padding-inline:8px}} +@media(max-width:800px){ + .review>header{padding:12px 14px;align-items:center}.review h1{font-size:25px}.review>header p{max-width:68vw;font-size:11px}.review .badge{display:none}.review>.preview-note{padding:6px 14px}.review>.round-summary{padding:6px 14px;max-height:76px;gap:4px 8px}.round-summary p{max-width:calc(100% - 44px);gap:4px 10px;font-size:11px}.round-summary p>span{font-size:10px}.round-summary button{font-size:11px} + .mobile-panes{display:flex;flex-shrink:0;gap:4px;padding:7px 14px;border-bottom:1px solid var(--line);background:var(--paper)}.mobile-panes button{flex:1;font-size:12px;min-height:32px;padding:5px;border-color:transparent;background:transparent}.mobile-panes button[aria-pressed=true]{background:#e8efec;color:var(--teal);box-shadow:none;border-color:#bdd0c8} + .review>.workbench{display:block;padding:12px 14px;min-height:0;overflow:hidden}.workbench[data-mobile-pane=component]>.reference,.workbench[data-mobile-pane=comp]>.inspector{display:none}.reference{height:100%;max-width:none}.connector{display:none}.inspector{height:100%;border:0}.map-legend{gap:6px 12px;font-size:10px}.map-caption{font-size:9px}.reference .section-head button{min-height:30px;padding:4px 8px}.reference .section-head h2{font-size:14px}.inspector>.section-head{min-height:26px;margin-bottom:0;padding:9px 12px}.inspection-content{padding:10px 8px 10px 12px}.inspector>.review-form{padding:8px 8px 8px 12px}.number{width:23px;height:23px;font-size:11px}.section-head h2{font-size:14px}.review-form{padding-top:8px}.decision-title strong{font-size:12px}.decision-title p{font-size:10px}.decisions>button{min-height:36px}.review-form .feedback{font-size:12px}.review-form .feedback textarea{min-height:60px}.compare{max-width:none} + .review>.inventory-section{height:160px;padding:6px 14px}.inventory-section>.section-head{flex-direction:row;flex-wrap:nowrap;align-items:center;gap:6px;min-height:34px;margin-bottom:4px}.inventory-section h2{display:none}.inventory-filters{flex:1;width:auto;min-width:0;padding:2px}.inventory-filters button{padding:4px 6px;font-size:10px;min-height:28px}.inventory-filters b{margin-left:3px}.tray-actions #show-all{display:none}.tray-actions #toggle-tray{padding:6px;min-width:32px;min-height:32px}.tray-actions #toggle-tray span{display:none}.tray-actions svg{width:16px;height:16px}.review>.inventory-section.tray-collapsed{height:46px;padding:6px 14px}.review>.inventory-section.tray-expanded{height:160px}.inventory .item{flex-basis:130px;grid-template-rows:auto 1fr auto;padding:6px}.inventory .item-thumb{display:none}.inventory.all{display:flex;overflow-x:auto;overflow-y:hidden}.inventory .item strong{font-size:11px;min-height:22px}.inventory .state{font-size:10px}.inventory .item-number{font-size:10px;min-height:14px}.inventory-empty{padding:8px 0;font-size:12px} + .review>footer{padding:8px 14px calc(8px + env(safe-area-inset-bottom));gap:8px;flex-direction:column;align-items:stretch}.review>footer>div:first-child{gap:10px;justify-content:space-between}.review>footer #approve-rest{font-size:10px;min-height:32px;max-width:47%;padding:5px 8px}.review>footer .check{font-size:10px;max-width:48%;gap:4px}.review>footer .check input{width:14px;height:14px}.review>footer .submit-area{justify-content:space-between;gap:10px}.review>footer .submit-area p{font-size:10px;max-width:22ch}.review>footer .primary{min-height:34px;font-size:12px;padding:6px 10px} +} +`; diff --git a/ui/component-review/entry.ts b/ui/component-review/entry.ts new file mode 100644 index 000000000..dbe6c652c --- /dev/null +++ b/ui/component-review/entry.ts @@ -0,0 +1,21 @@ +import { mountComponentReview } from './review'; +import type { Draft, ReviewPacket, ReviewHistory } from './model'; + +async function start() { + const host=document.getElementById('review')!; + const response=await fetch('/packet',{cache:'no-store'}); + if(!response.ok)throw new Error('The review packet could not be loaded. Reload to retry.'); + const state=await response.json() as {packet:ReviewPacket;draft:Draft;receipt:unknown;history:ReviewHistory|null;sourceStatus:string|null}; + if(state.sourceStatus){const notice=document.createElement('p');notice.textContent=state.sourceStatus;host.before(notice);} + mountComponentReview(host,state.packet,{ + initialDraft:state.draft, + history:state.history, + completed:!!state.receipt, + onSubmit:async(value)=>{ + const response=await fetch('/decision',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(value)}); + const result=await response.json(); + if(!response.ok)throw new Error(result.error??'The review could not be saved. Try again.'); + }, + }); +} +start().catch(error=>{const p=document.createElement('p');p.textContent=error instanceof Error?error.message:String(error);document.getElementById('review')?.replaceChildren(p);}); diff --git a/ui/component-review/fonts/albertsans-OFL.txt b/ui/component-review/fonts/albertsans-OFL.txt new file mode 100644 index 000000000..de087f444 --- /dev/null +++ b/ui/component-review/fonts/albertsans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Albert Sans Project Authors (https://github.com/usted/Albert-Sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/ui/component-review/fonts/albertsans.ttf b/ui/component-review/fonts/albertsans.ttf new file mode 100644 index 000000000..aa2811075 Binary files /dev/null and b/ui/component-review/fonts/albertsans.ttf differ diff --git a/ui/component-review/fonts/alumnisans-OFL.txt b/ui/component-review/fonts/alumnisans-OFL.txt new file mode 100644 index 000000000..4c108036f --- /dev/null +++ b/ui/component-review/fonts/alumnisans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2015 The Alumni Sans Project Authors (https://github.com/googlefonts/alumni) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/ui/component-review/fonts/alumnisans.ttf b/ui/component-review/fonts/alumnisans.ttf new file mode 100644 index 000000000..b6ef931f8 Binary files /dev/null and b/ui/component-review/fonts/alumnisans.ttf differ diff --git a/ui/component-review/fonts/jetbrainsmono-OFL.txt b/ui/component-review/fonts/jetbrainsmono-OFL.txt new file mode 100644 index 000000000..821a3dac2 --- /dev/null +++ b/ui/component-review/fonts/jetbrainsmono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/ui/component-review/fonts/jetbrainsmono.ttf b/ui/component-review/fonts/jetbrainsmono.ttf new file mode 100644 index 000000000..aa310be8b Binary files /dev/null and b/ui/component-review/fonts/jetbrainsmono.ttf differ diff --git a/ui/component-review/icons.ts b/ui/component-review/icons.ts new file mode 100644 index 000000000..390660d06 --- /dev/null +++ b/ui/component-review/icons.ts @@ -0,0 +1,18 @@ +/** Small, consistent utility icons; callers supply accessible action labels. */ +const paths = { + code: '', + image: '', + expand: '', + compact: '', + hideTray: '', + showTray: '', + next: '', + mark: '', + close: '', + undo: '', + external: '', + zoom: '', +} as const; +export function icon(name: keyof typeof paths): string { + return ``; +} diff --git a/ui/component-review/model.test.ts b/ui/component-review/model.test.ts new file mode 100644 index 000000000..8c3080117 --- /dev/null +++ b/ui/component-review/model.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test'; +import { approveRemaining, newDraft, submission, summarize, validBox, type ReviewPacket } from './model'; +const packet: ReviewPacket = { id:'review-1', revision:'packet-1', title:'Test', round:1, comp:{url:'/comp.png',width:100,height:100}, components:[{id:'art',revision:'art-1',name:'Art',medium:'Raster',note:'',box:{x:0,y:0,w:1,h:1},preview:{kind:'image',url:'/art.png'}},{id:'control',revision:'control-1',name:'Button',medium:'HTML',note:'',box:{x:0,y:0,w:.1,h:.1},preview:{kind:'page',url:'/page.html'}}] }; +describe('component review drafts',()=>{ + test('bulk approval still requires explicit inventory confirmation',()=>{const draft=approveRemaining(packet,newDraft(packet));expect(summarize(packet,draft).canSubmit).toBe(false);draft.inventoryConfirmed=true;expect(submission(packet,draft).requestId).toBe(packet.id);}); + test('approve remaining preserves requests for repair and their feedback',()=>{const draft=newDraft(packet);draft.decisions.art={revision:'art-1',action:'revise',feedback:'Keep the original motif',split:true};const bulk=approveRemaining(packet,draft);expect(bulk.decisions.art).toEqual(draft.decisions.art);expect(summarize(packet,bulk)).toMatchObject({approved:1,revisions:1,canSubmit:true});}); + test('stale packet cannot submit',()=>{const draft=approveRemaining(packet,newDraft(packet));draft.inventoryConfirmed=true;expect(()=>submission({...packet,revision:'packet-2'},draft)).toThrow('stale');}); + test('changed component version invalidates only that decision',()=>{const draft=approveRemaining(packet,newDraft(packet));draft.inventoryConfirmed=true;const changed={...packet,components:packet.components.map(c=>c.id==='art'?{...c,revision:'art-2'}:c)};expect(summarize(changed,draft)).toMatchObject({approved:1,pending:1,canSubmit:false});}); + test('a missing item can be submitted without approving unrelated components',()=>{const draft=newDraft(packet);draft.missing.push({id:'missing-1',name:'Brushwork',box:{x:.2,y:.3,w:.2,h:.2},feedback:'Missing texture'});expect(summarize(packet,draft)).toMatchObject({pending:2,hasFeedback:true,canSubmit:true});draft.missing[0].name=' ';expect(summarize(packet,draft).canSubmit).toBe(false);}); + test('rejects invalid regions and does not share mutable submission state',()=>{expect(validBox({x:0,y:0,w:0,h:1})).toBe(false);expect(validBox({x:.9,y:0,w:.5,h:1})).toBe(false);expect(validBox({x:NaN,y:0,w:1,h:1})).toBe(false);const draft=approveRemaining(packet,newDraft(packet));draft.inventoryConfirmed=true;const receipt=submission(packet,draft);draft.decisions.art.feedback='Changed';expect(receipt.decisions.art.feedback).toBe('');}); +}); + + +test('draft-only or changed prior decisions never count as carried approval', async () => { + const { repairStatus } = await import('./model'); + const history: any = { + submitted: false, packet: { round: 1 }, + draft: { decisions: { art: { action: 'approve' } } }, + changes: { art: { kind: 'unchanged', files: [], reasons: [] } }, + }; + expect(repairStatus('art', history).carried).toBe(false); + history.submitted = true; + history.changes.art.carried = true; + expect(repairStatus('art', history).carried).toBe(true); + history.changes.art.kind = 'changed'; + expect(repairStatus('art', history).carried).toBe(false); + expect(repairStatus('art', history).label).toBe('Review again'); +}); + + +test('attention states cannot hide a component behind a stale approval', async () => { + const { componentState } = await import('./model'); + const draft=approveRemaining(packet,newDraft(packet)); + expect(componentState(packet.components[0],draft).kind).toBe('approved'); + expect(componentState({...packet.components[0],revision:'new'},draft).kind).toBe('pending'); + draft.decisions.art.action='revise'; + expect(componentState(packet.components[0],draft)).toMatchObject({kind:'feedback',label:'Feedback ready'}); +}); + +test('captured code keeps its implementation identity without trusting medium as proof', async () => { + const { componentPresentation } = await import('./model'); + const image = packet.components[0]; + expect(componentPresentation({...image, medium:'SVG'}).code).toBe(false); + const captured = {...packet.components[1], preview:{kind:'image' as const, sourceKind:'page' as const, url:'/capture.png'}}; + expect(componentPresentation(captured)).toMatchObject({code:true,captured:true,label:'HTML',caption:'Rendered component',fileLabel:'Open captured preview'}); + expect(componentPresentation(packet.components[1]).caption).toBe('Live component'); +}); diff --git a/ui/component-review/model.ts b/ui/component-review/model.ts new file mode 100644 index 000000000..666317505 --- /dev/null +++ b/ui/component-review/model.ts @@ -0,0 +1,82 @@ +export type Box = { x: number; y: number; w: number; h: number }; +export type Component = { + id: string; revision: string; name: string; medium: string; note: string; box: Box; + material?: { format: string; width: number; height: number; alpha: 'transparent' | 'opaque' | 'unknown' }; + context?: { kind?: 'image' | 'page'; sourceKind?: 'page'; url: string; layering: string }; + thumbnail?: { url: string; box?: Box }; + preview: { kind: 'image' | 'page'; sourceKind?: 'page'; url: string; position?: string }; +}; +export type ReviewPacket = { + id: string; revision: string; title: string; round: number; + comp: { url: string; width: number; height: number; background?: string }; components: Component[]; +}; +export type Decision = { revision: string; action: 'approve' | 'revise'; feedback: string; split: boolean }; +export type Missing = { id: string; name: string; box: Box; feedback: string }; +export type Draft = { packetRevision: string; decisions: Record; missing: Missing[]; inventoryConfirmed: boolean }; +export type ReviewHistory = { + packet: ReviewPacket; draft: Draft; submitted: boolean; + changes: Record; + feedback?: Record; + removed: { id: string; name: string }[]; +}; +export function repairStatus(id: string, history?: ReviewHistory | null) { + const change = history?.changes[id]; + const outstanding = history?.feedback?.[id]; + const prior = outstanding?.decision ?? (history?.submitted ? history.draft.decisions[id] : undefined); + const carried = change?.kind === 'unchanged' && (change.carried ?? (history?.submitted && prior?.action === 'approve')) === true; + return { + change, prior, feedbackRound: outstanding?.round ?? history?.packet.round, + carried, + label: change?.kind === 'added' ? 'New component' + : change?.kind === 'changed' ? 'Review again' + : carried ? 'Approval kept' + : prior?.action === 'revise' ? 'Changes still requested' : 'Awaiting review', + }; +} +export function componentState(component: Component, draft: Draft, history?: ReviewHistory | null) { + const saved = draft.decisions[component.id]; + const decision = saved?.revision === component.revision ? saved : undefined; + const repair = repairStatus(component.id, history); + if (decision?.action === 'approve') return { kind: 'approved' as const, label: repair.carried ? 'Approval kept' : 'Approved', priority: 3 }; + if (decision?.action === 'revise') return { kind: 'feedback' as const, label: 'Feedback ready', priority: 2 }; + return { kind: 'pending' as const, label: repair.change?.kind === 'changed' ? 'Review again' : repair.change?.kind === 'added' ? 'New · review needed' : 'Not reviewed', priority: repair.change?.kind === 'changed' || repair.change?.kind === 'added' ? 0 : 1 }; +} +export function newDraft(packet: ReviewPacket): Draft { + return { packetRevision: packet.revision, decisions: {}, missing: [], inventoryConfirmed: false }; +} +export function validBox(b: Box) { + return Object.values(b).every(Number.isFinite) && b.x >= 0 && b.y >= 0 && b.w > 0 && b.h > 0 && b.x + b.w <= 1.00001 && b.y + b.h <= 1.00001; +} +export function summarize(packet: ReviewPacket, draft: Draft) { + const decisions = packet.components.map(c => draft.decisions[c.id]?.revision === c.revision ? draft.decisions[c.id] : undefined); + const approved = decisions.filter(d => d?.action === 'approve').length; + const revisions = decisions.filter(d => d?.action === 'revise').length; + const pending = decisions.length - approved - revisions; + const hasFeedback = revisions > 0 || draft.missing.length > 0; + return { approved, revisions, pending, hasFeedback, + canSubmit: draft.packetRevision === packet.revision && draft.missing.every(m => m.name.trim() && validBox(m.box)) && (hasFeedback || (!pending && draft.inventoryConfirmed)) }; +} +export function approveRemaining(packet: ReviewPacket, draft: Draft): Draft { + const decisions = { ...draft.decisions }; + for (const c of packet.components) { + if (!decisions[c.id] || decisions[c.id].revision !== c.revision) decisions[c.id] = { revision: c.revision, action: 'approve', feedback: '', split: false }; + } + return { ...draft, decisions }; +} +/** UI drafts are not authority. A trusted adapter must verify versions and actor before persisting. */ +export function submission(packet: ReviewPacket, draft: Draft) { + if (!summarize(packet, draft).canSubmit) throw new Error('Review is incomplete or stale'); + return { schemaVersion: 1, requestId: packet.id, ...structuredClone(draft) }; +} + +/** A code capture is an image for display, but is still a code implementation. */ +export function componentPresentation(component: Component) { + const code = component.preview.kind === 'page' || component.preview.sourceKind === 'page'; + const captured = component.preview.sourceKind === 'page'; + return { + code, captured, + label: code ? (component.medium.match(/html|css|svg/i) ? component.medium : 'HTML / CSS / SVG') : 'Raster', + caption: code ? (captured ? 'Rendered component' : 'Live component') : 'Produced asset', + fileLabel: captured ? 'Open captured preview' : 'Open source image', + }; +} diff --git a/ui/component-review/review.ts b/ui/component-review/review.ts new file mode 100644 index 000000000..c577c3785 --- /dev/null +++ b/ui/component-review/review.ts @@ -0,0 +1,249 @@ +import { componentPresentation, approveRemaining, componentState, repairStatus, newDraft, submission, summarize, type Box, type Draft, type ReviewPacket, type ReviewHistory } from './model'; +import { comparisonSize } from './viewport'; +import { styles } from './styles'; +import { icon } from './icons'; + +const esc = (s: string) => s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!)); +const pct = (n: number) => `${n * 100}%`; +// Only trusted adapter URLs may enter frames/images. Never accept javascript: or executable data URLs. +const url = (s: string) => { + const parsed = new URL(s, location.href); + if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Unsupported preview URL'); + return esc(parsed.href); +}; +export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, options: { + preview?: boolean; history?: ReviewHistory | null; initialDraft?: Draft; completed?: boolean; onSubmit: (value: ReturnType) => Promise; +}) { + const root = host.attachShadow({mode: 'open'}); + let draft = structuredClone(options.initialDraft ?? newDraft(packet)); + const orderedComponents = () => [...packet.components].sort((a,b)=>componentState(a,draft,options.history).priority-componentState(b,draft,options.history).priority); + let selected = orderedComponents().find(c=>componentState(c,draft,options.history).kind!=='approved')?.id ?? packet.components[0]?.id; + let inventoryFilter: 'attention' | 'approved' | 'all' = options.completed ? 'all' : 'attention'; + let marking = false; + let sending = false; + let submitted = options.completed ?? false; + let error = ''; + let overlay = false; + let showAll = false; + let trayOpen = true; + let mobilePane: 'comp' | 'component' = 'comp'; + let renderedMobilePane = mobilePane as 'comp' | 'component'; + let zoom: 'fit' | number = 'fit'; + let backdrop: 'checker' | 'page' = 'checker'; + let previousRound = false; + let outputMode: 'isolated' | 'context' = 'isolated'; + let renderedSelection: string | undefined; + let renderedZoom: 'fit' | number = 'fit'; + let drag: { x: number; y: number } | null = null; + let dragBox: Box | null = null; + let resize: ResizeObserver | null = null; + const checkIcon = ''; + const feedbackIcon = ''; + const boxStyle = (b: Box) => `left:${pct(b.x)};top:${pct(b.y)};width:${pct(b.w)};height:${pct(b.h)}`; + function updateDecision(action: 'approve' | 'revise') { + const c = packet.components.find(c => c.id === selected); + if (!c) return; + draft.decisions[c.id] = {revision:c.revision, action, feedback:draft.decisions[c.id]?.feedback ?? '', split: action === 'revise' && (draft.decisions[c.id]?.split ?? false)}; + render(); + if(action==='revise') { + const field=root.querySelector('#feedback'); + field?.focus({preventScroll:true}); + const form=root.querySelector('.review-form'); + if(form&&field){const overflow=field.getBoundingClientRect().bottom-form.getBoundingClientRect().bottom;if(overflow>0)form.scrollTop+=overflow+8;} + const pane=root.querySelector('.inspection-content'); + const comparison=root.querySelector('.compare'); + if(pane&&comparison)pane.scrollTop+=comparison.getBoundingClientRect().top-pane.getBoundingClientRect().top; + + } + } + function addMissing(box: Box) { + const id = `missing-${crypto.randomUUID()}`; + draft.missing.push({ id, name:'Missing component', feedback:'', box }); + draft.inventoryConfirmed = false; + selected = id; mobilePane='component'; marking = false; drag = null; dragBox = null; + render(); + root.querySelector('#missing-name')?.focus(); + } + function render() { + resize?.disconnect(); + const active = root.activeElement as HTMLElement | null; + const focusId = active?.id; + const focusSelection = active?.dataset.select; + const scrollX = window.scrollX, scrollY = window.scrollY; + const railLeft = root.querySelector('.inventory')?.scrollLeft ?? 0; + const keepInspector=renderedSelection===selected; + const focusMobileComparison=mobilePane==='component'&&(!keepInspector||renderedMobilePane!==mobilePane); + renderedMobilePane=mobilePane; + const inspectorTop=keepInspector?(root.querySelector('.inspection-content')?.scrollTop??0):0; + const filesOpen=keepInspector&&(root.querySelector('.changed-files')?.open??false); + const oldPane=root.querySelector('.pan-viewport'); + const retainPan=renderedSelection===selected&&renderedZoom===zoom; + const panLeft=retainPan?(oldPane?.scrollLeft??0):0, panTop=retainPan?(oldPane?.scrollTop??0):0; + renderedSelection=selected;renderedZoom=zoom; + const c = packet.components.find(c => c.id === selected); + const missing = draft.missing.find(m => m.id === selected); + const box = c?.box ?? missing?.box; + const index = c ? packet.components.indexOf(c) + 1 : packet.components.length + draft.missing.findIndex(m => m.id === selected) + 1; + const savedDecision = c ? draft.decisions[c.id] : undefined; + const d = savedDecision?.revision === c?.revision ? savedDecision : undefined; + const stats = summarize(packet, draft); + const history = options.history; + const repair = c ? repairStatus(c.id, history) : undefined; + const priorComponent = history?.packet.components.find(item=>item.id===c?.id); + const viewingPrevious = previousRound && !!priorComponent; + const v = viewingPrevious ? priorComponent : c; + const vp = viewingPrevious ? history!.packet : packet; + const changes = Object.values(history?.changes??{}); + const changedCount = changes.filter(change=>change.kind==='changed').length; + const addedCount = changes.filter(change=>change.kind==='added').length; + const carriedCount = packet.components.filter(item=>componentState(item,draft,history).kind==='approved'&&repairStatus(item.id,history).carried).length; + const stateFor = (item: typeof packet.components[number]) => componentState(item,draft,history); + const attentionCount = packet.components.length-stats.approved+draft.missing.length; + const shownComponents = (inventoryFilter==='attention'?orderedComponents():packet.components).filter(item=>inventoryFilter==='all'||(stateFor(item).kind==='approved')===(inventoryFilter==='approved')); + const summaryDetails = [ + stats.revisions+draft.missing.length ? `${stats.revisions+draft.missing.length} feedback ready` : '', + carriedCount ? `${carriedCount} ${carriedCount===1?'approval':'approvals'} kept` : '', + changedCount ? `${changedCount} changed` : '', + addedCount ? `${addedCount} added` : '', + history?.removed.length ? `${history.removed.length} removed` : '', + ].filter(Boolean).join(' · '); + const statusMessage = error || (submitted ? (options.preview ? 'Preview submitted. No run changed.' : 'Review submitted.') : stats.hasFeedback ? 'Ready to send for corrections.' : stats.pending ? `${stats.pending} left to review` : !draft.inventoryConfirmed ? 'Confirm the map is complete.' : 'Ready to continue.'); + const presentation = v ? componentPresentation(v) : null; + const isRaster = v?.preview.kind === 'image' && !presentation?.code; + const useContext = !!(v?.context && outputMode === 'context'); + const useFrame = v && (useContext ? v.context?.kind !== 'image' : v.preview.kind === 'page'); + const sourceUrl = useContext && v?.context ? v.context.url : v?.preview.url; + const materialLabel = presentation?.code ? `${presentation.label} · ${presentation.captured ? 'captured from code' : 'live preview'}` : v?.material ? `${v.material.alpha === 'transparent' ? 'Transparent' : v.material.alpha === 'opaque' ? 'Opaque' : 'Transparency unverified'} ${v.material.format}` : 'Raster · transparency unverified'; + root.innerHTML = `
+

Review the components.

${esc(packet.title)} · Round ${packet.round}

${options.preview ? 'Interactive preview' : ''}
+ ${options.preview ? '

Historical hotel artwork for testing this interface. Decisions stay in this preview; no run is changed.

' : ''} + ${history ? `

${stats.pending} ${stats.pending===1?'component':'components'} to review${summaryDetails}

${stats.pending?``:''}${history.removed.length?`
Removed from the map

${history.removed.map(item=>esc(item.name)).join(' · ')}. Confirm these omissions are intentional before accepting the map.

`:''}
`:''} +
+
+
+

Approved comp

+
+ Approved composition for ${esc(packet.title)} + ${box ? `
` : ''} + ${packet.components.map((item,i) => {const state=stateFor(item);return ``}).join('')} + ${draft.missing.map((item,i)=>``).join('')} + + +
+
# To review${feedbackIcon} Feedback ready${checkIcon} Approved
+ ${marking ? '
Draw around the missing piece.
' : ''} +
+
+

${index} ${esc(c?.name ?? missing?.name ?? 'Component')}

+ ${c ? `
${icon(presentation!.code ? 'code' : 'image')}${esc(materialLabel)}${v?.material ? `${v.material.width} × ${v.material.height} px` : ''}${v?.preview.kind==='image'?`${icon('external')}`:''}
+ ${history ? `
+ ${repair?.prior?.action==='revise'?`

Previous feedback Round ${repair.feedbackRound}

Needs work

${esc(repair.prior.feedback || 'No written feedback was supplied.')}
${repair.prior.split?'

Requested: split into separately reviewable components.

':''}
`:repair?.carried?`

Unchanged · approval kept

`:''} + ${repair?.change?.kind==='changed'?`
${repair.change.files.length?`${repair.change.files.length} changed ${repair.change.files.length===1?'file':'files'}`:repair.change.reasons.includes('region')?'Region changed':priorComponent?.note!==c.note?'Description changed · files unchanged':'Component definition changed · files unchanged'}${repair.change.files.length?`
    ${repair.change.files.map(path=>`
  • ${esc(path)}
  • `).join('')}
`:''}${priorComponent&&priorComponent.note!==c.note?`
Previous description
${esc(priorComponent.note)}
Current description
${esc(c.note)}
`:''}
`:''} +
`:''} + ${priorComponent?`
`:''} +
+
+
${viewingPrevious ? `Comp · Round ${history!.packet.round}` : 'In the comp'}
Reference region for ${esc(v!.name)}
+
${viewingPrevious ? `Previous · Round ${history!.packet.round}` : history ? `Current · Round ${packet.round}` : useContext ? 'In the page' : presentation!.caption}
${!useFrame ? `Produced ${esc(v!.name)}` : ``}${overlay ? `Reference overlay` : ''}
+
+ ${isRaster ? `
${v!.context ? `
` : ''}
` : ''} +

${esc(v?.context?.layering ?? 'Layer placement not recorded.')}

+

${esc(v!.note)}

+
${viewingPrevious?'

Viewing the previous round. Return to Current to make a decision.

':''}
Your review Round ${packet.round}${viewingPrevious?'

Return to Current to review this round.

':''}
${d ? `` : ''}
+ ${d?.action === 'revise' ? `` : ''} +
` : missing ? `

This piece will be added to the unresolved inventory.

${(['x','y','w','h'] as const).map(k=>``).join('')}
` : '

No components supplied.

'} +
+ +

Components

+
${shownComponents.map(item=>{const i=packet.components.indexOf(item);const state=stateFor(item); return ``}).join('')}${(inventoryFilter==='approved'?[]:draft.missing).map((m,i)=>``).join('')}${!shownComponents.length&&(inventoryFilter==='approved'||!draft.missing.length)?`

${inventoryFilter==='attention'?'Every component is approved. Confirm the map is complete, then continue.':'No components approved yet.'}

`:''}
+

${esc(statusMessage)}

+ `; + if(viewingPrevious)root.querySelectorAll('.decisions button,#feedback,#split,#approve-rest,#submit,#inventory-confirm').forEach(el=>el.disabled=true); + root.querySelector('.inspection-content')!.scrollTop=inspectorTop; + if(submitted||sending)root.querySelectorAll('.decisions button,#approve-rest,#mark,#inventory-confirm,#missing-name,#missing-feedback,#feedback,#split,#remove-missing,[data-coordinate]').forEach(el=>el.disabled=true); + root.querySelector('.inventory')!.scrollLeft = railLeft; + if(!keepInspector&&trayOpen)Array.from(root.querySelectorAll('.inventory [data-select]')).find(el=>el.dataset.select===selected)?.scrollIntoView({block:'nearest',inline:'nearest'}); + if (focusId) root.getElementById(focusId)?.focus({preventScroll:true}); + else if(focusSelection) Array.from(root.querySelectorAll('.item[data-select]')).find(el=>el.dataset.select===focusSelection)?.focus({preventScroll:true}); + const on = (id:string, action:()=>void) => root.querySelector(`#${id}`)?.addEventListener('click', action); + root.querySelectorAll('[data-select]').forEach(el => el.onclick = () => {if(marking)return; selected=el.dataset.select!; mobilePane='component'; overlay=false; zoom='fit'; outputMode='isolated'; previousRound=false; render();}); + on('previous-round',()=>{previousRound=true;render();}); + on('current-round',()=>{previousRound=false;render();}); + on('review-changes',()=>{const pending=orderedComponents().filter(item=>stateFor(item).kind==='pending');const current=pending.findIndex(item=>item.id===selected);const next=pending[(current+1)%pending.length];if(next){selected=next.id;mobilePane='component';inventoryFilter='attention';previousRound=false;zoom='fit';overlay=false;outputMode='isolated';render();}}); + root.querySelectorAll('[data-filter]').forEach(button=>button.onclick=()=>{ + inventoryFilter=button.dataset.filter as typeof inventoryFilter; + const matches=orderedComponents().filter(item=>inventoryFilter==='all'||(stateFor(item).kind==='approved')===(inventoryFilter==='approved')); + const selectedMissing=inventoryFilter!=='approved'&&draft.missing.some(item=>item.id===selected); + if(!selectedMissing&&!matches.some(item=>item.id===selected)&&matches.length){selected=matches[0].id;mobilePane='component';previousRound=false;zoom='fit';overlay=false;outputMode='isolated';} + render(); + }); + on('approve',()=>updateDecision('approve')); on('revise',()=>updateDecision('revise')); + on('clear',()=>{if(c)delete draft.decisions[c.id]; render();}); + on('overlay',()=>{overlay=!overlay; render();}); + on('isolated',()=>{outputMode='isolated';render();}); + on('context',()=>{outputMode='context';render();}); + root.querySelector('#zoom')?.addEventListener('change',e=>{const value=(e.target as HTMLSelectElement).value;zoom=value==='fit'?'fit':Number(value);render();}); + on('background-checker',()=>{backdrop='checker';render();}); + on('background-page',()=>{backdrop='page';render();}); + on('show-all',()=>{showAll=!showAll;trayOpen=true;render();}); + on('toggle-tray',()=>{trayOpen=!trayOpen;render();}); + on('show-comp',()=>{mobilePane='comp';render();}); + on('show-component',()=>{mobilePane='component';render();}); + on('mark',()=>{marking=!marking;render();}); on('add-box',()=>addMissing({x:.35,y:.35,w:.2,h:.2})); + on('remove-missing',()=>{draft.missing=draft.missing.filter(m=>m.id!==selected);selected=packet.components[0]?.id;render();}); + on('approve-rest',()=>{draft=approveRemaining(packet,draft);render();}); + root.querySelector('#inventory-confirm')?.addEventListener('change',e=>{draft.inventoryConfirmed=(e.target as HTMLInputElement).checked;render();}); + root.querySelector('#feedback')?.addEventListener('input',e=>{if(c)draft.decisions[c.id].feedback=(e.target as HTMLTextAreaElement).value;}); + root.querySelector('#split')?.addEventListener('change',e=>{if(c)draft.decisions[c.id].split=(e.target as HTMLInputElement).checked;}); + root.querySelector('#missing-name')?.addEventListener('input',e=>{if(missing)missing.name=(e.target as HTMLInputElement).value; const submit=root.querySelector('#submit');if(submit)submit.disabled=!summarize(packet,draft).canSubmit;}); + root.querySelector('#missing-feedback')?.addEventListener('input',e=>{if(missing)missing.feedback=(e.target as HTMLTextAreaElement).value;}); + root.querySelectorAll('[data-coordinate]').forEach(el=>el.addEventListener('change',()=>{ + if(!missing)return; const k=el.dataset.coordinate as keyof Box; + const next=Number(el.value)/100; + if(Number.isFinite(next)) missing.box[k]=Math.max(k==='w'||k==='h'?.001:0, Math.min(1,next)); + missing.box.w=Math.min(missing.box.w,1-missing.box.x);missing.box.h=Math.min(missing.box.h,1-missing.box.y);render(); + })); + on('submit',async()=>{sending=true;error='';render();try{await options.onSubmit(submission(packet,draft));submitted=true;}catch(e){error=e instanceof Error?e.message:'Could not save. Try again.';}finally{sending=false;render();}}); + const map = root.querySelector('.map')!; + function point(e: PointerEvent) {const r=map.getBoundingClientRect();return {x:Math.max(0,Math.min(1,(e.clientX-r.left)/r.width)),y:Math.max(0,Math.min(1,(e.clientY-r.top)/r.height))};} + map.addEventListener('pointerdown',e=>{if(!marking)return;drag=point(e);map.setPointerCapture(e.pointerId);e.preventDefault();}); + map.addEventListener('pointermove',e=>{if(!drag)return;const p=point(e);dragBox={x:Math.min(drag.x,p.x),y:Math.min(drag.y,p.y),w:Math.abs(p.x-drag.x),h:Math.abs(p.y-drag.y)};const outline=root.querySelector('.draw-box')!;outline.hidden=false;outline.style.cssText=boxStyle(dragBox);}); + map.addEventListener('pointerup',()=>{if(dragBox&&dragBox.w>.01&&dragBox.h>.01)addMissing(dragBox);else{drag=null;dragBox=null;}}); + map.addEventListener('pointercancel',()=>{drag=null;dragBox=null;render();}); + const stage=root.querySelector('.output'); const frame=root.querySelector('iframe'); + const workbench=root.querySelector('.workbench')!; + // Reflect native scroll position without re-rendering or resetting the inspector. + const inspectionBody=root.querySelector('.inspection-content'); + const inspectionPanel=root.querySelector('.inspector'); + function updateScrollEdges() { + if(!inspectionBody||!inspectionPanel)return; + inspectionPanel.dataset.scrollAbove=String(inspectionBody.scrollTop>1); + inspectionPanel.dataset.scrollBelow=String(inspectionBody.scrollHeight-inspectionBody.clientHeight-inspectionBody.scrollTop>1); + } + inspectionBody?.addEventListener('scroll',updateScrollEdges,{passive:true}); + function resizePreview() { + const mapSpace=root.querySelector('.map-space'); + if(mapSpace&&mapSpace.clientWidth&&mapSpace.clientHeight){ + const fit=comparisonSize(packet.comp.width,packet.comp.height,Math.max(1,mapSpace.clientWidth-32),Math.max(1,mapSpace.clientHeight-32),'fit'); + map.style.width=`${fit.width}px`;map.style.height=`${fit.height}px`; + } + const content=root.querySelector('.inspection-content'); + const panes=Array.from(root.querySelectorAll('.pan-viewport')); + if(content?.clientHeight)panes.forEach(p=>p.style.height=`${Math.min(248,Math.max(100,content.clientHeight-40))}px`); + if(v&&panes.length){const size=comparisonSize(v.box.w*vp.comp.width,v.box.h*vp.comp.height,Math.min(...panes.map(p=>p.clientWidth)),Math.min(...panes.map(p=>p.clientHeight)),zoom);root.querySelectorAll('.crop-stage').forEach(el=>{el.style.width=`${size.width}px`;el.style.height=`${size.height}px`;});} + if(stage&&frame&&v){const s=stage.clientWidth/(v.box.w*vp.comp.width);frame.style.transform=`scale(${s})`;frame.style.left=`${-v.box.x*vp.comp.width*s}px`;frame.style.top=`${-v.box.y*vp.comp.height*s}px`;} + const bounds=workbench.getBoundingClientRect();const region=root.querySelector('.region');const end=root.querySelector('.number'); + const path=root.querySelector('.connector path'); + if(region&&end&&path){const a=region.getBoundingClientRect(),b=end.getBoundingClientRect();const x1=a.right-bounds.left,y1=a.top+a.height/2-bounds.top,x2=b.left-bounds.left-8,y2=b.top+b.height/2-bounds.top;path.setAttribute('d',`M ${x1} ${y1} H ${x2-14} V ${y2} H ${x2}`);} + } + resize=new ResizeObserver(()=>{resizePreview();updateScrollEdges();});resize.observe(workbench);const content=root.querySelector('.inspection-content');if(content)resize.observe(content);resizePreview(); + const panes=Array.from(root.querySelectorAll('.pan-viewport')); + panes.forEach(pane=>{pane.scrollLeft=panLeft;pane.scrollTop=panTop;}); + panes.forEach(pane=>pane.addEventListener('scroll',()=>{for(const other of panes)if(other!==pane){if(other.scrollLeft!==pane.scrollLeft)other.scrollLeft=pane.scrollLeft;if(other.scrollTop!==pane.scrollTop)other.scrollTop=pane.scrollTop;}})); + if(focusMobileComparison&&window.matchMedia('(max-width:800px)').matches){const body=root.querySelector('.inspection-content');const comparison=root.querySelector('.compare');if(body&&comparison)body.scrollTop+=comparison.getBoundingClientRect().top-body.getBoundingClientRect().top;} + updateScrollEdges(); + window.scrollTo(scrollX,scrollY); + } + render(); + return {destroy(){resize?.disconnect();root.replaceChildren();},getDraft():Draft{return structuredClone(draft);}}; +} diff --git a/ui/component-review/styles.ts b/ui/component-review/styles.ts new file mode 100644 index 000000000..a5716d6f8 --- /dev/null +++ b/ui/component-review/styles.ts @@ -0,0 +1,36 @@ +import { appLayout } from './app-layout'; +export const styles = ` +:host{display:block;color:var(--color-text,#292929);font:14px/1.45 var(--font-sans,Arial,sans-serif);--line:var(--color-border,#ddd);--paper:var(--color-panel,#fff);--muted:var(--color-muted,#666);--teal:var(--color-patina,#28625e);--warn:var(--color-warn,#8a5b30);--selection:#43897f} +*{box-sizing:border-box}h1,h2,p,figure{margin:0}button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid var(--line);border-radius:4px;background:var(--paper);color:inherit;padding:8px 12px;min-height:36px}button:hover{border-color:var(--teal);color:var(--teal)}button:disabled{cursor:default;opacity:.45}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--teal);outline-offset:3px}button[aria-pressed=true]{box-shadow:inset 0 0 0 1px var(--teal)}input[type=checkbox]{accent-color:var(--teal);width:16px;height:16px;flex-shrink:0}textarea,input:not([type=checkbox]){width:100%;background:var(--paper);color:inherit;border:1px solid #999;border-radius:4px;padding:9px 10px}textarea{resize:vertical;min-height:80px}::selection{background:#c7ddd8}a{color:var(--teal)} +.review{max-width:1600px;margin:auto;padding:24px 28px 0}header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:16px}h1{font:400 40px/1.05 var(--font-display,Arial,sans-serif);letter-spacing:-.02em}header p{margin-top:8px;font-size:15px}header p span,.medium{color:var(--muted)}.badge{border:1px solid var(--line);padding:5px 10px;font-size:12px;white-space:nowrap}.preview-note{color:var(--muted);font-size:12px;border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:24px} +.connector{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:5;overflow:visible}.connector path{fill:none;stroke:var(--selection);stroke-width:2}.workbench{align-items:start;display:grid;grid-template-columns:minmax(0,1.18fr) minmax(0,1fr);gap:40px;position:relative}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;min-height:36px}.section-head h2{font-size:17px;font-weight:500;line-height:1.2}.section-head>span,.section-head h2>span:not(.number){font-size:12px;color:var(--muted)}.section-head button{font-size:12px}.number{display:inline-flex;align-items:center;justify-content:center;width:27px;height:27px;border:1px solid var(--teal);color:var(--teal);margin-right:7px;font:12px var(--font-mono,monospace)} +.map{position:relative;background:#eaeaea;isolation:isolate}.comp{width:100%;height:100%;display:block;user-select:none}.map.marking{touch-action:none;cursor:crosshair}.map.marking .pin{pointer-events:none;opacity:.25}.pin{position:absolute;transform:translate(-50%,-50%);padding:0;min-height:25px;width:25px;height:25px;border-radius:50%;border:1px solid #fff;background:#fff;color:#292929;font:11px var(--font-mono,monospace);box-shadow:0 1px 4px #0006;z-index:2}.pin.selected{background:var(--teal);color:#fff;border-color:var(--teal);box-shadow:none;outline:none;z-index:3}.pin:focus-visible{outline:2px solid white;outline-offset:3px}.region,.draw-box{position:absolute;pointer-events:none;outline:2px solid var(--selection);z-index:1}.draw-box{background:#28625e33;z-index:4}.map-caption{font-size:12px;color:var(--muted);padding-top:12px;min-height:40px}.map-caption button{display:block;margin-top:10px}.compare{display:grid;grid-template-columns:1fr 1fr;gap:12px}.compare figure{min-width:0}.compare figcaption{height:24px;min-height:0;font-size:12px;margin-bottom:9px}.compare figcaption span{display:block;color:var(--muted);font-size:11px}.crop-stage{position:relative;overflow:hidden;background:var(--comp-background,#eee);min-width:0}.crop-image{position:absolute;max-width:none;height:auto}.asset{display:block;width:100%;height:100%;object-fit:cover}.crop-stage iframe{position:absolute;max-width:none;border:0;transform-origin:top left;pointer-events:none}.overlay-image{opacity:.5;pointer-events:none}.component-note{font-size:12px;line-height:1.5;color:var(--muted);margin:6px 0 0}.decisions{display:flex;gap:10px;padding:10px 0;background:var(--paper);position:sticky;bottom:0;z-index:6}.decisions>button{flex:1;min-height:48px;font-weight:600;font-size:15px;border-color:var(--teal)}.decision-approve{background:var(--teal);color:white}.decision-approve:hover{background:#224e4b;color:white}.decision-revise{color:var(--teal);background:var(--paper)}.decisions>.quiet{flex:0;border:0;background:transparent;font-size:12px}.decisions .approved{color:var(--teal);background:#e9f0ed;border-color:var(--teal)}.decisions .revise{color:var(--warn);background:#f7f0e6;border-color:var(--warn)}.feedback{display:block;margin-top:16px;font-size:13px}.feedback>span{float:right;color:var(--muted);font-size:12px}.feedback textarea,.feedback input{display:block;margin-top:7px}.check{display:flex;align-items:center;gap:7px;font-size:12px;line-height:1.5;margin-top:12px}.coordinates{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:16px 0}.coordinates label{font-size:12px}.coordinates input{margin-top:5px} +.inventory-section{margin-top:28px;border-top:1px solid var(--line);padding-top:16px}.inventory-section .section-head{margin-bottom:10px}.inventory{display:flex;gap:8px;overflow-x:auto;padding:3px 3px 14px;scrollbar-color:#a6bcb8 #eee;scrollbar-width:thin}.item{position:relative;flex:0 0 134px;display:grid;grid-template-columns:20px 1fr;column-gap:8px;row-gap:3px;padding:10px;text-align:left;background:transparent}.item strong{grid-column:2;font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item.active{background:var(--paper);border-color:var(--teal)}.thumb-crop{position:relative;display:block;overflow:hidden}.item-thumb{display:flex;align-items:center;justify-content:center;grid-column:1/-1;position:relative;overflow:hidden;width:100%;height:76px;background:var(--comp-background,#eee);margin-bottom:7px}.item-thumb img{width:100%;height:100%;object-fit:contain}.item-thumb img[style]{height:auto}.inventory.all{display:grid;grid-template-columns:repeat(auto-fill,minmax(128px,1fr));overflow:visible}.item-number{grid-row:2/4;font:12px var(--font-mono,monospace);color:var(--muted);padding-top:2px}.state{grid-column:2;font-size:11px;color:var(--muted)}.state.approve{color:var(--teal)}.state.revise{color:var(--warn)}footer{display:flex;justify-content:space-between;gap:24px;padding:20px 0 24px;border-top:1px solid var(--line);margin-top:8px}.submit-area{display:flex;align-items:center;gap:20px}.submit-area p{font-size:12px;color:var(--muted);max-width:28ch}.primary{min-height:46px;font-weight:600;background:var(--teal);border-color:var(--teal);color:white;white-space:nowrap}.primary:hover{color:white;background:#224e4b}.primary:disabled{opacity:.45}.inspector>p{margin:12px 0} +@media(min-width:1300px){.workbench{gap:56px}.review{padding-top:32px}.component-note{max-width:62ch}} +@media(max-width:800px){.connector{display:none}.review{padding:20px 16px 0}.workbench{grid-template-columns:1fr;gap:24px}.reference{max-width:640px;margin:auto;width:100%}.inspector{border-top:1px solid var(--line);padding-top:16px}.compare{max-width:640px}.inventory-section .section-head{align-items:flex-start;flex-direction:column;gap:4px}footer{flex-direction:column}.submit-area{justify-content:space-between}.badge{font-size:11px}.section-head{gap:8px}h1{font-size:34px}header{align-items:flex-start}.section-head h2{font-size:16px}} + +.inspector{height:690px;overflow-y:auto;scrollbar-gutter:stable;scrollbar-width:thin;padding:0 5px 0 1px;overflow-anchor:none} +.material{display:flex;align-items:baseline;flex-wrap:wrap;gap:5px 12px;min-height:34px;margin-bottom:6px}.material strong{font-size:14px;font-weight:600}.material span{font:11px var(--font-mono,monospace);color:var(--muted)} +.compare-toolbar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:16px}.compare-toolbar label{font-size:12px;display:flex;align-items:center;gap:8px}select{font:inherit;color:inherit;background:var(--paper);border:1px solid #999;border-radius:4px;padding:7px 9px;min-height:36px}select:focus-visible,.pan-viewport:focus-visible{outline:2px solid var(--teal);outline-offset:2px} +.overlay-control{display:flex;align-items:center;gap:8px;border-color:var(--teal);color:var(--teal);font-weight:500}.overlay-control[aria-pressed=true]{background:#e9f0ed}.overlay-control svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.3} +.pan-viewport{height:248px;overflow:auto;display:flex;background:#efefef;scrollbar-width:thin;scrollbar-color:#829b98 #eee;overscroll-behavior:contain}.crop-stage{flex-shrink:0;margin:auto}.checker{background-color:#eee;background-image:conic-gradient(#c7c7c7 25%,transparent 0 50%,#c7c7c7 0 75%,transparent 0);background-size:16px 16px} +.view-controls{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-top:12px;min-height:36px}.view-controls>div{display:flex;background:#eee;border:1px solid var(--line);padding:2px;border-radius:4px}.view-controls button{border:0;background:transparent;font-size:12px;min-height:30px;padding:5px 9px}.view-controls button[aria-pressed=true]{background:var(--paper);box-shadow:0 1px 2px #0002}.view-controls label{font-size:11px;display:flex;gap:6px;align-items:center}.view-controls select{font-size:11px;min-height:32px;padding:5px}.view-controls>span{font-size:12px;color:var(--muted)}.scale-note{font-size:11px;color:var(--muted);margin:10px 0 0;min-height:18px}.scale-note a{white-space:nowrap} +.component-details{height:78px;overflow:auto;margin-top:12px;padding-right:4px;scrollbar-width:thin}.layering{font-size:12px;line-height:1.5}.component-details .component-note{margin-top:6px} +#approve-rest{min-height:44px;font-weight:600;border-color:var(--teal);color:var(--teal)} +@media(max-width:800px){.inspector{height:720px}.pan-viewport{height:248px}.component-details{height:92px}.view-controls label{font-size:11px}.review header{align-items:flex-start}} + +.round-summary{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 20px;padding:14px 0;margin-bottom:22px;border-block:1px solid var(--line)}.round-summary p{display:flex;flex-wrap:wrap;gap:5px 18px;font-size:13px}.round-summary p>span{color:var(--muted)}.round-summary details{flex-basis:100%;font-size:12px}.round-summary details p{font-size:12px;margin-top:8px;max-width:80ch} +.repair-context{margin:0 0 16px;font-size:12px}.previous-feedback{margin:0;padding:12px 14px;background:#f0f0ed;border-radius:4px;overflow-wrap:anywhere}.previous-feedback h3{margin:0;font-size:13px;font-weight:600;display:flex;align-items:baseline;justify-content:space-between;gap:12px}.previous-feedback h3 span{font-size:11px;color:var(--muted);font-weight:400}.previous-feedback .previous-verdict{color:var(--warn);font-size:11px;margin-top:6px}.previous-feedback blockquote{margin:4px 0 0;white-space:pre-wrap;font-size:13px;line-height:1.5}.previous-feedback>p:last-child:not(.previous-verdict){margin-top:8px}.kept-approval{color:var(--teal);font-size:12px} +.preview-round{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 14px;font-size:12px}.preview-round>span{font-weight:500}.round-switch{display:flex;gap:2px;border:1px solid var(--line);border-radius:4px;padding:2px;background:#eee}.round-switch button{min-height:30px;font-size:11px;padding:5px 8px;border:0;background:transparent}.round-switch button[aria-pressed=true]{background:var(--paper);box-shadow:0 1px 2px #0002} +.changed-files{margin-top:10px;color:var(--muted)}summary{cursor:pointer;padding:5px 0;min-height:30px}summary:focus-visible{outline:2px solid var(--teal);outline-offset:2px}.changed-files ul{padding-left:18px;margin:6px 0;overflow-wrap:anywhere;font:11px/1.6 var(--font-mono,monospace)}.description-diff{margin:6px 0 12px}.description-diff dt{font-weight:600;font-size:11px;margin-top:10px}.description-diff dd{margin:4px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--color-text,#292929)}.previous-notice{font-size:12px;color:var(--muted);margin-top:8px}.inspector .previous-notice{display:none} +.decisions{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:8px 10px;padding-top:12px}.decision-title{grid-column:1/-1}.decision-title strong{font-size:14px;font-weight:600;display:flex;justify-content:space-between;gap:12px}.decision-title strong span{font-size:11px;color:var(--muted);font-weight:400}.decision-title p{font-size:12px;font-weight:400;color:var(--muted);margin-top:4px}.decisions>.quiet{align-self:center;padding-inline:4px}.decisions:not(:has(.quiet)){grid-template-columns:1fr 1fr} +.inspector{display:flex;flex-direction:column;overflow:hidden;padding:0}.inspection-content{flex:1;min-height:0;overflow:auto;scrollbar-width:thin;scrollbar-gutter:stable;overscroll-behavior:contain;padding:0 5px 10px 1px}.review-form{flex-shrink:0;padding:12px 5px 0 1px;border-top:1px solid var(--line);background:var(--paper)}.review-form .decisions{position:static;padding:0 0 8px;background:transparent}.review-form .feedback{margin-top:8px}.review-form .feedback textarea{min-height:72px;max-height:120px}.review-form .check{margin:8px 0;font-size:11px} +.inspection-content:focus-visible{outline:2px solid var(--teal);outline-offset:-2px} +.inspector>.section-head{flex-shrink:0;padding:0 5px 0 1px} +.pin{display:flex;align-items:center;justify-content:center;gap:3px;font-size:12px;font-weight:600;width:30px;height:30px;min-height:30px}.pin svg,.map-legend svg,.item-number svg{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}.pin.pending,.pin.pending.selected{background:#f4cd68;border-color:#f4cd68;color:#3c300f}.pin.approved,.pin.approved.selected{width:38px;min-height:24px;height:24px;background:#edf3ef;border-color:#edf3ef;color:#326258;border-radius:4px}.pin.approved:not(.selected){opacity:.55;box-shadow:none}.pin.approved:hover,.pin.approved:focus-visible{opacity:1}.pin.feedback,.pin.feedback.selected{width:38px;background:#964b2f;border-color:#fff;color:#fff;border-radius:4px}.pin.selected{outline:none;border:3px solid #fff;box-shadow:none;z-index:3}.pin:focus-visible{outline:2px solid #fff;outline-offset:3px} +.map-legend{display:flex;flex-wrap:wrap;gap:9px 18px;margin-top:14px;font-size:11px;color:var(--muted)}.map-legend>span{display:flex;align-items:center;gap:6px}.map-legend i{display:inline-flex;align-items:center;justify-content:center;min-width:21px;height:21px;font-style:normal}.legend-pending{background:#f4cd68;color:#3c300f;border-radius:50%}.legend-feedback{background:#964b2f;color:#fff;border-radius:3px}.legend-approved{background:#edf3ef;color:#326258;border-radius:3px}.map-caption{font-size:11px} +.inventory-section .section-head{flex-wrap:wrap}.inventory-filters{display:flex;gap:3px;padding:3px;background:#eee;border-radius:5px}.inventory-filters button{border:0;background:transparent;min-height:34px;padding:7px 10px;font-size:12px;white-space:nowrap}.inventory-filters button[aria-pressed=true]{background:var(--paper);color:var(--teal);box-shadow:0 1px 2px #0002}.inventory-filters b{margin-left:5px;font-weight:600;font-variant-numeric:tabular-nums}.item.pending{border-color:#b18a2f;background:#fffbef}.item.feedback{border-color:#964b2f;background:#fcf1eb}.item.approved{background:#f1f4f2;border-color:#ccd8d1}.item.approved .item-thumb{opacity:.65}.item.active{outline:2px solid var(--teal);outline-offset:0;box-shadow:none}.item-number{display:flex;align-items:center;gap:3px;grid-column:1/-1;grid-row:auto;min-height:20px;font-weight:600}.item strong{grid-column:1/-1;font-size:13px;white-space:normal;min-height:36px;line-height:1.35}.state{grid-column:1/-1;font-size:12px;font-weight:600;padding-top:6px;border-top:1px solid #0002}.state.pending{color:#725819}.state.feedback{color:#914429}.state.approved{color:#326258}.inventory-empty{padding:20px 0;font-size:13px;color:var(--muted)} +@media(max-width:800px){.inventory-section .section-head{gap:10px}.inventory-filters{width:100%}.inventory-filters button{padding-inline:7px;font-size:11px;flex:1}.inventory-filters b{margin-left:3px}} +@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}} +.item-medium{margin-left:auto;font-weight:400;display:flex;align-items:center;gap:4px;font-size:10px;color:var(--muted);min-height:16px}.item-medium .utility-icon{width:14px;height:14px;flex-shrink:0}.material>.utility-icon{width:18px;height:18px;align-self:center;color:var(--teal)} +` + appLayout; diff --git a/ui/component-review/viewport.test.ts b/ui/component-review/viewport.test.ts new file mode 100644 index 000000000..40a7402f6 --- /dev/null +++ b/ui/component-review/viewport.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from 'bun:test'; +import { comparisonSize } from './viewport'; + +test('portrait regions fit the available height without distorting the reference', () => { + const size = comparisonSize(768, 922, 210, 248, 'fit'); + expect(size.height).toBe(248); + expect(size.width).toBeCloseTo(206.577, 2); + expect(size.width / size.height).toBeCloseTo(768 / 922); +}); +test('zoom uses comp pixels, including tiny texture regions and thin controls', () => { + expect(comparisonSize(154, 102, 210, 248, 4)).toEqual({scale:4,width:616,height:408}); + expect(comparisonSize(1440, 4, 210, 248, 1)).toEqual({scale:1,width:1440,height:4}); +}); diff --git a/ui/component-review/viewport.ts b/ui/component-review/viewport.ts new file mode 100644 index 000000000..dd1f4db37 --- /dev/null +++ b/ui/component-review/viewport.ts @@ -0,0 +1,5 @@ +/** Both panes use comp pixels and the same scale; source-image resolution is separate. */ +export function comparisonSize(width: number, height: number, availableWidth: number, availableHeight: number, zoom: 'fit' | number) { + const scale = zoom === 'fit' ? Math.min(availableWidth / width, availableHeight / height) : zoom; + return { scale, width: width * scale, height: height * scale }; +}