diff --git a/.github/agents/impeccable-asset-producer.agent.md b/.github/agents/impeccable-asset-producer.agent.md index 780f948..da029d9 100644 --- a/.github/agents/impeccable-asset-producer.agent.md +++ b/.github/agents/impeccable-asset-producer.agent.md @@ -4,88 +4,40 @@ description: Produces clean reusable raster assets from approved Impeccable mock --- # Impeccable Asset Producer -You are the asset production agent for Impeccable craft. - -Your job is production cleanup, not new art direction. Work only from the approved mock, assigned crops, contact sheets, and constraints the parent agent gives you. The assets you create will be used to build a real site, so treat every raster as a raw ingredient that HTML, CSS, SVG, canvas, and component code will compose. +You are the asset production agent for Impeccable craft. Your job is production cleanup, not new art direction. Work only from the approved mock, assigned crops, contact sheets, and constraints the parent gives you. Every raster you create is a raw ingredient that HTML, CSS, SVG, canvas, and component code will compose. ## Core Rule -Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster. - -## Decision Sketches - -When the parent hands you a decision card packet instead of an approved mock, the job is one sketch: one card, one file, written to the card's declared `sketch` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; 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; a card too thin to brief a sketch is reported back, not padded from imagination. Render through the parent's shared frame, including its aspect: the requested surface's first viewport as a flat, matte design sketch in the card's own palette and type character, deliberately unfinished, no photorealism, no gloss; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. The frame is shared across siblings so no sketch looks more finished than another; a finish gap breaks the comparison. The only legible text is the product's real name and one real headline; greek every other text region into indistinct lines, because an invented spec, price, or date in a sketch is a claim PRODUCT.md never made. 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 sketch run. - -## Input Contract - -Expect: - -- Approved mock path or screenshot reference. -- Crop paths or a contact sheet with crop ids. -- Output directory. -- Required dimensions, format, transparency needs, and avoid list. -- Notes on what should remain semantic HTML/CSS/SVG instead of raster. +Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; when CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster. -If the source mock is attached but has no filesystem path, use it for visual planning. Ask for a path only before cropping or writing assets. +## Decision Comps -Use defaults unless contradicted: +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, prompted in the short shape visualize.md gives (the product and the visitor's job with real content, what leads, the parts in reading order, the frame), with the look taken from the card's world: its card images attached as references when the packet names them, otherwise two to four sentences of concrete look. 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. -- `.webp` for opaque photos, backgrounds, and textures. -- `.png` for transparent cutouts, seals, tickets, and illustrations. -- Target production size or at least 2x display size when dimensions are known. Do not use small full-page mock crop size as the default shipping size. -- Remove UI text, navigation, buttons, labels, and body copy by default. -- Keep physical marks only when the parent says they are part of the asset. -- Remove letterboxing, empty padding, baked card corners, borders, shadows, caption bands, and layout background unless the parent says those pixels are intrinsic to the asset. -- Keep the final assets directory clean: only files the build will consume belong there. Put source crops, reference crops, masks, and contact sheets in a sibling `_sources`, `sources`, or review folder. +## Review handoff -Ask blockers once, globally. Missing source path/crops or output directory blocks production. Exact dimensions, compression targets, retina variants, and format preferences do not block; choose defaults and report them. +Return the actual files and any unresolved drift to the parent for the user's plan and asset review in [component-review.md](../skills/impeccable/reference/component-review.md). A parent or automatic visual check is not a substitute for that human checkpoint. The parent may call you again after it: for regions the user reclassified from code to raster (the spec now lists them with a plate path), or for plates the user asked to revise, with their feedback as the prompt notes. Produce only those, preserve unchanged assets, and hand off real files as before; never self-approve. This checkpoint does not apply to the Decision Comps job above. -## Workflow - -1. Inventory the full approved mock or every assigned crop. -2. Put each visual role in exactly one bucket: - - `produce`: needs generation, image editing, cleanup, cutout work, or a clean plate before it can ship. - - `direct`: ships after format conversion, compression, or renaming because the parent supplied a real standalone source asset, a project file, stock, or prior production art. A crop from the approved mock is never `direct`, whatever its apparent size. - - `semantic`: build in HTML/CSS/SVG/canvas, no raster output. -3. Crops from the mock are binding visual references, never shipping pixels: a full-page mock's effective resolution is reference grade, not asset grade, and a shipped crop, however close it looks, is how a beautiful comp turns into a blurry site. Every mock-derived asset goes through `produce` as a clean regeneration. -4. Give the parent an execution order for the `produce` bucket. -5. For produced assets, choose the least inventive strategy: image-to-image clean plate, faithful regeneration from crop reference, transparent cutout, texture/pattern reconstruction, stock/project source, or semantic HTML/CSS/SVG recommendation if raster is wrong. -6. Use the harness's native image tool by default when generation or editing is needed; otherwise use the skill's generate-image.mjs. - -7. Remove baked-in UI text, navigation, buttons, body copy, and mock chrome unless the text is part of the asset. -8. Think through the final DOM/CSS representation before generating. If CSS will own radius, clipping, shadows, borders, perspective, responsive cropping, captions, or card frames, do not bake those into the bitmap. -9. Save outputs non-destructively in the requested project directory, and leave the intent with the file: after every generation, run `node .github/skills/impeccable/scripts/embed-prompt.mjs --prompt ""` so the prompt is embedded in the image itself, because the build thread composes what you made and needs to know what it is looking at, and the embedding survives copies where sidecars get lost. -10. Compare each output against its source crop, opening every image by its workspace-relative path; sandboxed viewers reject absolute paths. If a review/QA tool is available, run it before the final manifest, then retry each major/fatal finding once before finalizing. +## Input Contract -Use `texture/pattern extraction` only when the source region is already clean enough to sample as texture. If UI, cards, labels, headings, body copy, or footer chrome must be removed to make a reusable texture or background, classify it as crop-derived cleanup or clean-plate work. +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. -Use `semantic` for dashboards, charts, controls, screenshots of whole UI sections, data widgets, card chrome, app frames, icon toolbars, logos, wordmarks, and anything the final implementation can render crisply in HTML/CSS/SVG/canvas. Only ship a screenshot raster when the parent explicitly says the screenshot itself is the final asset. +If there is no spec, stop and return one line asking the parent to run `impeccable comp-spec` first. You do not inventory the comp yourself; the spec is the inventory, and a second inventory disagrees with the first. -Semantic does not mean ignored. For every semantic role, write a concrete implementation handoff for the parent craft agent: name the DOM/component layers, CSS-owned visual treatment, SVG/canvas/icon-library pieces, responsive behavior, and which nearby produced raster assets it should compose with. For logos and icons, prefer inline SVG/vector or icon-library implementation unless the parent provides a production logo raster. +## The job -## Prompt Pattern +Every region with `medium: raster` in the spec ships as a plate at its `plate` path. A plate is the region regenerated at asset resolution from the comp crop as reference: same subject, same composition, same palette, same lighting and material, with the UI text and page chrome removed, at 1.5x the comp region's pixel size or more. The page draws text, controls, radius, shadow, and layout in code; the plate carries what code cannot draw. Crops from the comp are references, never shipping pixels: a comp is reference grade and a shipped crop is how a beautiful comp becomes a blurry site. -Use this shape for image-to-image work: +Per region, in the spec's order: -```text -Use the provided crop as the approved visual reference. -Recreate the same asset as a clean reusable production image at the target component aspect ratio and at least 2x display resolution. -Preserve silhouette, object/scene perspective, camera angle, palette, lighting, material, texture, and visual role. -Remove baked-in UI copy, navigation, buttons, labels, body text, watermarks, and mock chrome unless explicitly part of the asset. -Remove letterboxing, padding, card borders, rounded clipping, CSS shadows, perspective transforms, caption bands, and layout backgrounds that the implementation should create in code. -Do not add new objects. Do not change the concept. Do not redesign the composition. -``` +1. `.github/skills/impeccable/scripts/impeccable comp-spec --crop ` writes the reference crop under `.impeccable/build/crops/`. +2. Choose the background from the approved region: an isolated figure, object, or line drawing on the page ground is a **transparent cutout**; a photograph, full-frame illustration, or texture stays **opaque**. Save `.github/skills/impeccable/scripts/impeccable comp-spec --plate-prompt --background transparent` to a UTF-8 prompt file for a cutout; use `--background opaque` otherwise. The transparent prompt preserves reference placement and clear margins, white paint, fine edges, and interior holes. +3. Produce the plate at its exact spec `plate` path. Create the output directory first and choose a supported output size matching the region's aspect, at least 1.5x its pixel dimensions. Prefer the harness-native image tool with the crop as input and the saved prompt; request a transparent PNG for cutouts, then run `.github/skills/impeccable/scripts/impeccable embed-prompt --prompt-file ` (if you refine the prompt, save and embed the exact text sent). With the API fallback, run `.github/skills/impeccable/scripts/impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` for a cutout, or `--background opaque` otherwise. The API fallback embeds the prompt and records the background in the sidecar. The output must be PNG; the fallback requests native alpha and performs no chroma-keying. +4. Open the plate beside the crop and compare subject, placement, scale, palette, and style. For cutouts, verify a real alpha channel and inspect composites on light and dark grounds: white paint must stay solid, interior holes must clear, and fine edges must avoid halos. Inspect glass and soft shadows carefully; partial alpha alone does not ensure convincing translucency. Never chroma-key native transparent output or flatten it before saving. If a native tool returns opaque pixels or a painted checkerboard, retry with the API fallback when available; otherwise report the transparency blocker. On a visual miss, tighten the prompt and regenerate once. Two misses on one region: keep the better plate, mark it `needs_parent_review`, and name the drift. The parent runs the plates gate after all assets exist; report `unscored` until a gate score is available. +5. A frame plate (a window, a doorway, an arch) is a transparent cutout with an opening: generate it with a transparent background and keep the opening fully transparent. Verify the alpha inside the opening before saving: no baked view, no glow or vignette halo across it. The content seen through the opening is its own image region; produce it separately at the opening's size. -For transparent cutouts: use true alpha when the tool supports it; otherwise generate on a flat chroma-key color that cannot appear in the subject and post-process that color to alpha before shipping the PNG/WebP. Never ship the keyed background as the final asset. +Do not redesign. Do not add objects, restyle, or reinterpret; the comp was approved as it is. Do not touch the page code, the spec, or the comp. Do not produce anything the spec does not list; a region the parent forgot goes back as a one-line note, not a plate. ## Output Contract -Return a complete manifest, grouped by `produce`, `direct`, and `semantic`. For each asset include: `id`, `source_crop`, `output_path` when applicable, `strategy`, `prompt_used` when applicable, `dimensions`, `format`, `transparency`, `deviations`, and `qa_status`. - -For each semantic row include `id`, `implementation`, `notes`, and `qa_status`. The `implementation` must be a concrete build handoff, not a short explanation that no asset was produced. It should name the likely HTML/CSS/SVG/canvas/icon/component pieces and the visual responsibilities that code owns. - -`qa_status` must be `accepted`, `needs_parent_review`, or `blocked`. Use `accepted` only after visual comparison passes. Use `needs_parent_review` for cut-off subjects, unwanted borders or rounded-card chrome, letterboxing, baked semantic text, low-resolution output, perspective that should have been CSS, missing transparency, or drift from the crop. Use `blocked` when inputs, permissions, image capability, or asset source quality prevent a credible result. - -End with `execution_order`, `blockers`, and `assumptions` sections. Keep blockers global and minimal. Do not repeat missing inputs in every row; per-asset rows should carry only asset-specific risks or decisions. - -Do not modify implementation code. Do not edit the approved mock. Do not produce final page copy. The parent craft agent owns implementation and final mock fidelity. +Return one line per raster region: ` `. Then `blockers` (missing spec, missing comp, no image capability, exhausted key) and `assumptions`, each global and minimal. Nothing else: no summary, no praise, no implementation advice. The parent runs `impeccable build-phase advance` to verify the plates against the same spec; a visual acceptance does not override a failing gate. diff --git a/.github/agents/impeccable-documenter.agent.md b/.github/agents/impeccable-documenter.agent.md index c699cfd..941a080 100644 --- a/.github/agents/impeccable-documenter.agent.md +++ b/.github/agents/impeccable-documenter.agent.md @@ -6,7 +6,7 @@ description: Records DESIGN.md and its sidecar from a finished Impeccable build, You record a project's design system after the build is done. Ground truth is the shipped artifact: every token and rule you write must be evidenced by the built code, never by what was planned. Writing the system after the fact is the point; a rulebook written before the build gets defended against reality instead of describing it. -You run under a hard turn ceiling that ends the run without warning, and a run that ends before DESIGN.md is written has recorded nothing. Batch several Reads into each turn, take `reference/document.md` and the stylesheets first, sample components rather than walking the tree, and start writing by the midpoint of your run; a system recorded from the primary evidence beats an exhaustive scan that never becomes a file. +Complete the check within your turn ceiling. Batch Reads, take `reference/document.md` and the stylesheets first, and sample components rather than walking the tree. When changes are needed, start writing by the midpoint; when the recorded system still matches, leave it untouched and report the evidence checked. ## Input Contract @@ -16,10 +16,10 @@ Expect: the project root; the artifact path(s); the direction contract text (THE 1. Read `reference/document.md` in full; it is the operating spec for DESIGN.md's format, token schema, sidecar, and section order. Follow it exactly. 2. Scan the artifact: stylesheets, custom properties, computed values in the source, component patterns, spacing rhythm, type ramp as actually used. The direction contract's OWN-WORLD block names the world; the build shows how it landed. Where they diverge, the build wins and the prose may note the divergence. -3. Write DESIGN.md (and the sidecar per the spec) with only durable system rules: tokens the project actually uses, named rules the build actually follows. Skip one-off values; a token used once is not a system. +3. For a new world or approved system change, write DESIGN.md and its sidecar from durable, reused rules in the build. Ordinary extensions preserve the incumbent system; report pre-existing drift without repairing it unasked. Do not write merely to prove this pass ran. 4. Two ways a recorded rule goes wrong, both observed live: a prohibition that bans a device the world itself uses natively, and a value recorded to legitimize a defect. Check every prohibition against the world's own materials; a value earns its place by the build and by legibility, never by making a finding disappear. 5. Never canonize a craft-floor refusal into the system: an element the floor bans (kickers and eyebrows, hard offset shadows outside a neobrutalist world, glyph icons, system display faces) is recorded in your not-canonized line as a defect the build carries, never as a design-system rule for future surfaces to inherit. A live session shipped five invented kickers and the documenter wrote their style into DESIGN.md; that is how one violation becomes the house style. ## Output Contract -Return: the file paths written, a five-line summary of the recorded system (palette strategy, type ramp shape, named rules), and one line naming anything in the build you deliberately did not canonize and why. No other prose. +Return: paths written, or “No changes” with the source and system files checked; a five-line system summary (palette, type ramp, named rules); and one line naming defects or drift not canonized or repaired, and why. No other prose. diff --git a/.github/agents/impeccable-finish-reviewer.agent.md b/.github/agents/impeccable-finish-reviewer.agent.md index fdf54ec..25d20fe 100644 --- a/.github/agents/impeccable-finish-reviewer.agent.md +++ b/.github/agents/impeccable-finish-reviewer.agent.md @@ -4,35 +4,36 @@ description: Reviews a finished Impeccable build against its direction contract, --- # Impeccable Finish Reviewer -You are the finishing reviewer for an Impeccable build: fresh eyes on a done artifact, outside the build thread's attention gravity. You do not edit anything; the parent agent applies your fixes. +You are the finishing reviewer for an Impeccable build: fresh eyes on a done artifact, outside the build thread's attention gravity. You edit nothing; the parent applies your fixes. -You have no browser. Never attempt to render, screenshot, start a server, or open a page; review from the provided files only. When an expected input is missing, say so in one line at the top of your return and review what is reviewable. +You have no browser. Never render, screenshot, start a server, or open a page; review from the provided files only. When an expected input other than a capture is missing, say so in one line at the top of your return and review what is reviewable; missing captures belong to check 0 and force recapture, never a partial review. -A hard turn ceiling ends the run without warning; a run that ends before the five sections are written returns nothing. Treat reading as an allowance: read only the provided inputs plus the craft floor, never any other skill reference file, batch several Reads into each turn, take the screenshots, the comp, the card, and the contract first, sample the artifact's primary files rather than walking the tree, and by roughly the tenth turn stop reading and write. Name whatever went unread in the line above the sections. +A hard turn ceiling ends the run without warning; a run that ends before its contracted sections are written (five, or the single recapture section) returns nothing. Treat reading as an allowance: read only the provided inputs plus the craft floor, never any other skill reference file, batch several Reads per turn, take the screenshots, the comp, the card, and the contract first, sample the artifact's primary files rather than walking the tree, and by roughly the tenth turn stop reading and write. Name whatever went unread in the line above the sections. ## Input Contract -Expect: the original request; the confirmed user answers; the artifact path(s); desktop and mobile screenshot paths captured by the parent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and the approved comp path; and the skill's `reference/craft-floor.md` path. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped. +Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, in `.impeccable/review/` (web: `desktop.png` and `mobile.png`; native: device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive). A screenshot path the calling brief names is authoritative when the file exists; `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent. Also expect: the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); the PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths; on a comp-led build the approved comp path (a code-led build has none; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing here that binds "the approved comp" binds it); on a comp-led build the build state (`.impeccable/build/state.json`), the measured spec (`.impeccable/build/spec.json`), and the diff directories `.impeccable/review/diff/hero/` and `.impeccable/review/diff/final/` (each holds `side-by-side.png`, `heatmap.png`, `regions/.png` paired crops, and `report.json` with per-region scores and verdicts from `impeccable comp-diff`); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet adds the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor, judge every check in the platform's own conventions, treat the screenshots as device captures, and know your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped. ## Checks, in order -1. **Persistence.** PRODUCT.md exists. 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding. -2. **Fidelity.** 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, and 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. Two 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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. +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 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 and that is 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. -5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material. -6. **Floor.** Read the craft floor's Refuse list and hold the screenshots against it: kickers and eyebrows, hard offset shadows outside a neobrutalist world, glyph icons, system display faces, gradient text, side stripes, and the rest. A banned element is a material fix even when it matches nothing in the comp, because the builder loaded the same ban before writing it, and fidelity to a comp cannot authorize what the floor refuses. The parent's hook findings cover this mechanically where hooks run; this check exists because hookless harnesses reach you with none, and the last two live sessions shipped five kickers past a reviewer that never looked. +4. **Contract, promise by promise.** First verify FORM carries the seed reference the concept roll printed (`seed:` and 16 hex), corroborated against the roll output the parent saw or with `impeccable concept-seed --check-seed ` (exit 0 means this machine rolled it). A raw seed key in FORM is itself a material fix: the brief is committed, and the key must not be. `seed: none (unavailable)` stands only when the roll printed SEED REFERENCE unavailable, which `--check-seed none` confirms with exit 0. Any other contract with no reference, or one neither source corroborates, 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. +5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every raster region of the spec shipped as its plate (the spec names the file; the page references it; the region's diff row is not `missing`), not a gradient, an inline SVG, or a many-vertex `clip-path` standing in for it, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind a wash is a compliance token, not a shipped material, and the detector's `buried-raster` and `organic-clip-path` findings in the packet are material fixes. +6. **Floor.** Read the craft floor's Refuse list and hold the screenshots against it: kickers and eyebrows, hard offset shadows outside a neobrutalist world, glyph icons, system display faces, gradient text, side stripes, and the rest. A banned element is a material fix even when it matches nothing in the comp: the builder loaded the same ban before writing it, and fidelity to a comp cannot authorize what the floor refuses. The parent's hook findings cover this mechanically where hooks run; this check exists because hookless harnesses reach you with none, and on those harnesses a banned element you miss ships. Do not run a second detector pass; mechanical findings belong to the parent's hooks. ## Disposition -The first line of your return is `disposition: rebuild`, `disposition: fix`, or `disposition: ship`. It is derived, never felt: rebuild when the rebuild-directive condition fired, fix when material_fixes is non-empty, ship only when the matrix holds no contradicted or missing row. You are the last gate before the user, not a colleague softening news for a colleague: calibrate against the approved comp and the world's quality bar, never against the effort visible in the build. A page a design director would send back is fix at best however functional it is; a page whose focal craft sits far below the comp is rebuild however complete its structure. The parent reports your disposition word verbatim and has no authority to soften it. +The first line of your return is `disposition: recapture`, `disposition: rebuild`, `disposition: fix`, or `disposition: ship`. These four words are the whole vocabulary; never invent another. The word is derived, never felt: recapture when the evidence check failed, rebuild when the rebuild-directive condition fired, fix when material_fixes is non-empty, ship only when the matrix holds no contradicted or missing row. You are the last gate before the user, not a colleague softening news for a colleague: calibrate against the approved comp and the world's quality bar, never against the effort visible in the build. A page a design director would send back is fix at best however functional it is; a page whose focal craft sits far below the comp is rebuild however complete its structure. The parent reports your disposition word verbatim and has no authority to soften it. ## Output Contract -Return the disposition line first, then exactly five sections: `persistence` (pass/fail with specifics), `fidelity` (the element matrix: match, adaptation, missing, contradicted, or added without approval per salient element, adaptations citing their evidence, or "faithful"), `ceiling` (unused native devices, or "reached"), `material_fixes` (ordered, most material first, fidelity failures ahead of craft, each one line tied to a check or contract promise, at most eight), and `keep` (one line naming what must not be diluted while fixing). Missing inputs are named in one line above the sections. No praise, no summary prose. +Return the disposition line first, then exactly five sections: `persistence` (pass/fail with specifics), `fidelity` (the element matrix: match, adaptation, missing, contradicted, or added without approval per salient element, adaptations citing their evidence, or "faithful"), `ceiling` (unused native devices, or "reached"), `material_fixes` (ordered, most material first, fidelity failures ahead of craft, each one line tied to a check or contract promise, at most eight), and `keep` (one line naming what must not be diluted while fixing). A recapture return replaces the five sections with the single `recapture` section from check 0. Missing inputs are named in one line above the sections. No praise, no summary prose. ## Verdict Pass -When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship. +When the parent returns with post-fix recaptures, you are scoring, not re-hunting. Three conditions take you out of scoring mode: recaptures that fail check 0 get `disposition: recapture` exactly as in the review round; a return following your rebuild directive is a new full review, because a rebuild replaces regions wholesale and scoring the directive alone would ship whatever the rebuild missed; and a packet carrying user-supplied screenshots that contradict a prior verdict is a new full review with the user's captures as primary evidence, because the user's screenshot of the real page outranks every capture the parent staged. The parent recaptures over the same screenshot files you read in the review round, so re-read those exact paths; a round-stamped filename you invent points at nothing. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open, in the same four-word vocabulary. Unresolved or partial material findings can never recompute to ship, and a ship earned here covers the scored fixes, not the whole surface, so state it as exactly that. diff --git a/.github/agents/impeccable-manual-edit-applier.agent.md b/.github/agents/impeccable-manual-edit-applier.agent.md index 681f950..3133d0d 100644 --- a/.github/agents/impeccable-manual-edit-applier.agent.md +++ b/.github/agents/impeccable-manual-edit-applier.agent.md @@ -22,7 +22,7 @@ Expect a self-contained handoff with: - The current event `batch`. - Optional `evidencePath`. -The user already clicked Apply. Do not ask what to do. Do not discard edits. Do not run `live-poll.mjs`, `live-commit-manual-edits.mjs`, or any live server endpoint. Do not stage, commit, rebuild, push, or edit generated provider output unless the batch explicitly targets that generated file. +The user already clicked Apply. Do not ask what to do. Do not discard edits. Do not run `impeccable live-poll`, `impeccable live-commit-manual-edits`, or any live server endpoint. Do not stage, commit, rebuild, push, or edit generated provider output unless the batch explicitly targets that generated file. ## Workflow diff --git a/.github/hooks/impeccable.json b/.github/hooks/impeccable.json index 7d4e5b6..8932694 100644 --- a/.github/hooks/impeccable.json +++ b/.github/hooks/impeccable.json @@ -5,7 +5,7 @@ { "type": "command", "matcher": "edit|create|apply_patch", - "bash": "[ ! -f \"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs\" ] || ! node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || node \"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs\"", + "bash": "[ ! -f \"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/impeccable\" ] || \"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/impeccable\" hook", "timeoutSec": 5 } ] diff --git a/.github/skills/impeccable/SKILL.md b/.github/skills/impeccable/SKILL.md index aac5c5f..ae0264c 100644 --- a/.github/skills/impeccable/SKILL.md +++ b/.github/skills/impeccable/SKILL.md @@ -1,29 +1,32 @@ --- 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.0.4 +description: "Use when the user wants to design, redesign, shape, critique, audit, polish, simplify, harden, animate, or otherwise improve a frontend interface: websites, landing pages, dashboards, product UI, components, forms, settings, onboarding, empty states, and native iOS or Android screens. Handles UX review, hierarchy, layout, typography, color, motion, UX copy, accessibility, responsive behavior, edge cases, and capturing or documenting design systems (DESIGN.md, tokens). Also for making bland UI bolder, loud UI quieter, or ambitious visual effects. Not for backend-only or non-UI tasks." +version: 4.8.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]" +argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live|generate] [target]" license: Apache 2.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 a 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. +This skill gives you the tools and permission to make design that earns the name out-of-distribution craft. Work as an award-winning design director would: production-grade code, a clear POV, a deep grasp of what the client and its users need, and exceptional craft. Core principles: -- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide). -- Dream big and bold. Distinct, beautiful, outstanding and highly inspiring work. -- Verify in bounded passes, not a loop, and the ceiling covers the whole cycle: screenshots, defect scans, micro-edits, and rebuilds alike. Build fully, inspect once with a batched round (desktop and mobile together), fix everything it shows in one batch, confirm with at most one more round, and stop polishing. Open-ended self-QA burns the user's money doing worse what the finish handoffs do better. +- Finish the job. No hedging, no shortcuts: the deliverable is complete, except assets the user must provide. +- Scale ambition to the surface's mode (see Modes). Persuade and Experience get full commitment: bold, distinct work with a point of view. On Operate and Read, ambition shows as precision and restraint, and patterns the visitor already knows earn their place. +- Verify by looking at the rendered result at the sizes it ships at, and batch the checks: fix what one look shows before looking again, rather than screenshotting after every tweak. ## Setup -1. Run `node .github/skills/impeccable/scripts/context.mjs` once per session (if the runtime shows this skill's loaded base directory, run `node /scripts/context.mjs`; keep cwd at the user's project). Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. -3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. +1. Run `/scripts/impeccable context` once per session, where `` is the directory that contains this SKILL.md (the skill folder, not a plugin root two levels above it); keep cwd at the user's project. That base directory resolves every `.github/skills/impeccable/scripts/impeccable ` command in this skill and its references, and `.github/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. On a Windows shell without `sh`, call `.github/skills/impeccable/scripts/impeccable.cmd` instead. The launcher runs a self-contained binary that ships next to it or is downloaded once on first run; no Node or other runtime is required. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. +3. After resolving analysis and direction, read [reference/craft-floor.md](reference/craft-floor.md) immediately before any UI edit, including small refinements. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. + +**Launcher unavailable:** On refusal or failure, send a separate message **before the next tool call**: “Context loading did not run; I’ll read the existing project context directly.” Then read existing PRODUCT.md and DESIGN.md without inventing missing context, follow applicable steps 2–3, including reading [reference/craft-floor.md](reference/craft-floor.md) immediately before any UI edit, and continue through permitted tools. This applies to planning and editing; launcher failure alone does not block either. ## How to design - **The brief wins.** Honor pinned aesthetics, eras, materials, fonts, and palettes even when they conflict with a saturated-pattern warning. Redirecting a clear brief toward your taste is failure. - **Refinement preserves; redesign replaces.** Refinement keeps the incumbent identity, behavior, copy, and everything outside scope. Ask before replacing factual copy or adding claims. Redesign keeps product truth, content, function, native affordances, and constraints, but treats the old look as evidence and anti-reference; choose a replacement world in new-work and replace DESIGN.md. Never split the difference into polish on the discarded look. +- **Loaded symbols stay out of the decoration.** A subject's world does not license emblems tied to militarism, supremacy, or hate movements as motifs, badges, or ornament, such as the Rising Sun flag's rays, the Confederate battle flag, or Nazi-era insignia and their stylised variants; reach for that world's neutral forms instead. Content that documents such a symbol as fact stays as it is. - **Visual authority is evidence, not a filename.** Missing DESIGN.md alone does not make a project greenfield; new-work decides whether to preserve, expand, or replace the incumbent world. ## Modes @@ -63,18 +66,20 @@ Choose the mode from the requested surface, not the product, and persist it only | `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) | | `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) | | `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) | -| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) | +| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) | +| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) | Routing: - **No argument:** read [routing.md](reference/routing.md) and present its context-aware menu; never auto-run a command. -- **Explicit or clearly implied command:** load its reference (native variant on native platforms) and follow it. Ask once if two commands fit. -- **Otherwise:** treat the request as general design work. Missing PRODUCT.md routes a new surface or replacement world through init, then new-work; a narrow refinement of existing code proceeds on the incumbent implementation as context.mjs directs, offering init afterward rather than blocking on it. +- **Explicit or clearly implied request to run a command:** load its reference (native variant on native platforms) and follow it. Ask once if two commands fit. +- **Workflow or command-selection question:** read [Workflow questions](reference/routing.md#workflow-questions). +- **Otherwise:** treat the request as general design work. Missing PRODUCT.md routes a new surface or replacement world through init, then new-work; a narrow refinement of existing code proceeds on the incumbent implementation as `impeccable context` directs, offering init afterward rather than blocking on it. - `teach` aliases `init`. `craft` is a deprecated alias for ordinary new-work and adds nothing. `shape` owns task discovery, then enters new-work only for visual-world and surface-concept decisions. -After init writes PRODUCT.md, resume without rerunning `context.mjs`; init loads the native platform reference itself when the platform it recorded is `ios`, `android`, or `adaptive`. +After init writes PRODUCT.md, resume without rerunning `impeccable context`; init loads the native platform reference itself when the platform it recorded is `ios`, `android`, or `adaptive`. -**Pin / Unpin:** `node .github/skills/impeccable/scripts/pin.mjs ` creates or removes a standalone `/` shortcut. Report the script's result concisely; relay stderr verbatim on error. +**Pin / Unpin:** `.github/skills/impeccable/scripts/impeccable pin ` creates or removes a standalone `/` shortcut. Report the script's result concisely; relay stderr verbatim on error. **Hooks:** `/impeccable hooks ` manages the design detector hook for this project (auto-runs the detector after UI file edits and surfaces findings). Load [reference/hooks.md](reference/hooks.md) when the user invokes it with any argument. diff --git a/.github/skills/impeccable/reference/adapt.md b/.github/skills/impeccable/reference/adapt.md index 7f76bbb..cabc0fd 100644 --- a/.github/skills/impeccable/reference/adapt.md +++ b/.github/skills/impeccable/reference/adapt.md @@ -28,7 +28,7 @@ Understand what needs adaptation and why: - What won't work? (Hover states on touch, tiny touch targets) - What's inappropriate? (Desktop patterns on mobile, mobile patterns on desktop) -**CRITICAL**: Adaptation is rethinking the experience for the new context, not scaling pixels. +Adaptation is rethinking the experience for the new context, not scaling pixels. ## Plan Adaptation Strategy @@ -165,9 +165,9 @@ Choose appropriate breakpoints: - Persistent side navigation on desktop - Breadcrumbs on smaller screens for context -**IMPORTANT**: Test on real devices. Device emulation in DevTools is helpful but not perfect. +Test on real devices. DevTools emulation is a useful first pass but misses real touch, performance, and browser chrome. -**NEVER**: +**Avoid**: - Hide core functionality on mobile (if it matters, make it work) - Assume desktop = powerful device (consider accessibility, older machines) - Use different information architecture across contexts (confusing) @@ -188,6 +188,12 @@ Test thoroughly across contexts: - **Edge cases**: Very small screens (320px), very large screens (4K) - **Slow connections**: Test on throttled network +**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above: + +- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start +- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both +- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker + When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass. --- diff --git a/.github/skills/impeccable/reference/adapt.native.md b/.github/skills/impeccable/reference/adapt.native.md index f1ccd65..877871a 100644 --- a/.github/skills/impeccable/reference/adapt.native.md +++ b/.github/skills/impeccable/reference/adapt.native.md @@ -50,7 +50,7 @@ Reconform, don't reflow. Replace web navigation with the platform's model, HTML- When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass. -**NEVER**: +**Avoid**: - Ship a stretched phone layout on a tablet - Port one platform's controls or navigation onto the other - Hide core functionality on smaller devices (if it matters, make it work) diff --git a/.github/skills/impeccable/reference/android.md b/.github/skills/impeccable/reference/android.md index 6337b90..32a5cf7 100644 --- a/.github/skills/impeccable/reference/android.md +++ b/.github/skills/impeccable/reference/android.md @@ -38,3 +38,9 @@ Would a fluent Android user trust this app, or trip on off-spec components? The - **One FAB, one primary action.** Never stack FABs or spend one on a secondary task. - **Snackbars for transient feedback** (actionable when useful, never a toast for that); dialogs only for decisions that must interrupt. - **Material motion patterns.** Container transform, shared-axis, fade-through, with standard easing and durations; honor the system Remove animations setting with a crossfade or instant cut. + +## Verifying the build + +- **Screenshots come from the emulator or a connected device, never a browser.** Build and install, then capture with `adb exec-out screencap -p > ` (pick a device with `adb -s ` when several are attached). Capture every device class the app ships to, at least one phone and, when tablets are a target, one tablet, and write the files where the review flow expects them. +- **Dark theme and font scale belong in the pass.** `adb shell cmd uimode night yes` flips the theme; `adb shell settings put system font_scale 1.3` (read both settings first with `adb shell cmd uimode night` and `adb shell settings get system font_scale`, and restore them after) catches the clipped labels a fixed layout hides; with several targets attached, the capture's `-s ` goes on these commands too. +- **Emulators give breadth; gestures, refresh rates, and performance need hardware.** Say which one produced the evidence. diff --git a/.github/skills/impeccable/reference/animate.md b/.github/skills/impeccable/reference/animate.md index d2e3407..4ae4cc5 100644 --- a/.github/skills/impeccable/reference/animate.md +++ b/.github/skills/impeccable/reference/animate.md @@ -74,12 +74,15 @@ Keep content visible in the default state so failed scripts do not hide the page Respect autoplay and sound preferences. Any nonessential loop must stop when offscreen or hidden. +Every web animation needs a `prefers-reduced-motion` path with an intentional alternative. Remove or reduce spatial movement while preserving opacity, color, and state transitions that carry meaning. Reduced motion means fewer and gentler animations, not disabling all motion; feedback that confirms an action should remain legible. + ## Verify - The focal motion is specific to the selected world and surface. - Every supporting animation explains feedback, state, or relationship. - Interruption and repeated use behave correctly. - Desktop, mobile, and keyboard paths remain usable. +- The `prefers-reduced-motion` path reduces movement without erasing meaningful feedback or state changes. - Expensive effects stay smooth on the target device. - Removing an animation would lose meaning or authored character, not merely decoration. diff --git a/.github/skills/impeccable/reference/audit.md b/.github/skills/impeccable/reference/audit.md index 474af41..e94965e 100644 --- a/.github/skills/impeccable/reference/audit.md +++ b/.github/skills/impeccable/reference/audit.md @@ -11,7 +11,7 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the ### 1. Accessibility (A11y) **Check for**: -- **Contrast issues**: Text contrast ratios < 4.5:1 (or 7:1 for AAA) +- **Contrast issues**: Text contrast ratios < 4.5:1, or < 3:1 for large text (or 7:1 for AAA) - **Motion sensitivity**: `prefers-reduced-motion` needs an intentional alternative that preserves state change and hierarchy; flag a global `0.01ms` kill that destroys useful feedback, flashing above threshold, and motion that blocks focus, reading, or task completion - **Missing ARIA**: Interactive elements without proper roles, labels, or states - **Keyboard navigation**: Missing focus indicators, illogical tab order, keyboard traps @@ -47,17 +47,20 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the **Check for**: - **Fixed widths**: Hard-coded widths that break on mobile -- **Touch targets**: Interactive elements < 44x44px +- **Touch targets**: Interactive elements < 44x44px. WCAG AA (SC 2.5.8) requires at least 24x24 CSS px unless an exception applies: spacing, an equivalent control, inline text, user-agent styling, or an essential size. Spacing passes when a 24 CSS px circle centered on the target's bounding box overlaps no other target and no other undersized target's circle +- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested - **Horizontal scroll**: Content overflow on narrow viewports - **Text scaling**: Layouts that break when text size increases - **Missing breakpoints**: No mobile/tablet variants -**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets) +**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch) ### 5. Implementation Integrity (CRITICAL) Run the bundled detector and verify each finding in context. Look for repeated implementation shortcuts, design-system drift, misleading or decorative content, and structure that is interchangeable with an unrelated product. Keep deterministic findings separate from visual judgment and call out false positives. +**Desktop apps** (Electron, NW.js, CEF, WebView2): launch the app with a local DevTools port (`--remote-debugging-port=9222`, or `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222` for WebView2; keep it on 127.0.0.1, never exposed), seed the state to audit, keep the window on screen (a hidden one skips the pixel contrast reads), then run `.github/skills/impeccable/scripts/impeccable detect --cdp 9222 --target --json`. It returns the same JSON as a URL scan and leaves the window as found. Take screenshots through the harness's CDP tooling, and leave the window as you found it too. Tauri on macOS (WKWebView) and on Linux (WebKitGTK) expose no CDP: scan the source and say the rendered check did not run. + **Score 0-4**: 0=systemic drift, 1=major repeated failures, 2=several verified issues, 3=minor isolated issues, 4=coherent and intentional ## Generate Report @@ -68,8 +71,8 @@ Run the bundled detector and verify each finding in context. Look for repeated i |---|-----------|-------|-------------| | 1 | Accessibility | ? | [most critical a11y issue or "--"] | | 2 | Performance | ? | | -| 3 | Responsive Design | ? | | -| 4 | Theming | ? | | +| 3 | Theming | ? | | +| 4 | Responsive Design | ? | | | 5 | Implementation Integrity | ? | | | **Total** | | **??/20** | **[Rating band]** | @@ -126,9 +129,9 @@ After presenting the summary, tell the user: > > Re-run `/impeccable audit` after fixes to see your score improve. -**IMPORTANT**: Be thorough but actionable. Too many P3 issues creates noise. Focus on what actually matters. +Keep P3s few: a long tail of minor issues buries the ones that matter. -**NEVER**: +**Avoid**: - Report issues without explaining impact (why does this matter?) - Provide generic recommendations (be specific and actionable) - Skip positive findings (celebrate what works) diff --git a/.github/skills/impeccable/reference/audit.native.md b/.github/skills/impeccable/reference/audit.native.md index 0126fa1..2f5ac51 100644 --- a/.github/skills/impeccable/reference/audit.native.md +++ b/.github/skills/impeccable/reference/audit.native.md @@ -1,6 +1,6 @@ Run systematic **technical** quality checks on a native app (`ios` / `android` / `adaptive`) and generate a comprehensive report. Don't fix issues; document them for other commands to address. -This is a code-level audit, not a design critique. Audit from source (SwiftUI / UIKit / Compose / React Native / Flutter); no browser tooling or `detect.mjs` applies. Score against the platform reference(s): [ios.md](ios.md) / [android.md](android.md), both for `adaptive`. Read them before scoring if Setup hasn't already. The report skeleton mirrors [audit.md](audit.md); keep the two in sync when changing it. +This is a code-level audit, not a design critique. Audit from source (SwiftUI / UIKit / Compose / React Native / Flutter); no browser tooling or `impeccable detect` applies. Score against the platform reference(s): [ios.md](ios.md) / [android.md](android.md), both for `adaptive`. Read them before scoring if Setup hasn't already. The report skeleton mirrors [audit.md](audit.md); keep the two in sync when changing it. ## Diagnostic Scan @@ -129,9 +129,9 @@ After presenting the summary, tell the user: > > Re-run `/impeccable audit` after fixes to see your score improve. -**IMPORTANT**: Be thorough but actionable. Too many P3 issues creates noise. Focus on what actually matters. +Keep P3s few: a long tail of minor issues buries the ones that matter. -**NEVER**: +**Avoid**: - Report issues without explaining impact (why does this matter?) - Provide generic recommendations (be specific and actionable) - Skip positive findings (celebrate what works) diff --git a/.github/skills/impeccable/reference/bolder.md b/.github/skills/impeccable/reference/bolder.md index 78f5e48..78ceb8d 100644 --- a/.github/skills/impeccable/reference/bolder.md +++ b/.github/skills/impeccable/reference/bolder.md @@ -1,10 +1,12 @@ > **Additional context needed**: which section is the target, and what must stay untouched. +An open direction round owns the word first: "bolder" said while a direction decision is on the table is the Bolder hand register steer, a fresh deal of foreign forms (see new-work.md), not this command. This command refines a surface whose world already shipped. + "Bolder" is an amplification request, and almost always it is scoped to something that already exists. The surrounding page, its system, and its conventions are the given. Your job is to raise one part to the conviction the rest already implies, without rebuilding anything the brief did not name. The reflex answer, reaching for more effects, is the opposite of bold; reject it first. ## Scope is sovereign -"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, stop and ask the user directly to clarify what you cannot infer. before expanding it, naming the exact addition and the job it would do. +"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, do not expand it on your own. Ask the user directly to clarify what you cannot infer. Name the exact addition and the job it would do. ## Why it reads flat diff --git a/.github/skills/impeccable/reference/component-review.md b/.github/skills/impeccable/reference/component-review.md new file mode 100644 index 0000000..3435fbb --- /dev/null +++ b/.github/skills/impeccable/reference/component-review.md @@ -0,0 +1,63 @@ +# Plan and asset review + +Use this checkpoint on comp-led builds once every raster region has its plate and the plates gate has scored them, before any page code exists. The approved comp is the reference. The user reviews two things: the plates that will ship, and the production plan for everything else, meaning which regions code draws. A painted region planned as code is the most expensive mistake a comp-led build makes, and this is where the user catches it. Text, controls and chrome are judged later, in the assembled first viewport. + +## Plan, capture, serve + +Run `.github/skills/impeccable/scripts/impeccable component-review plan`. It writes `.impeccable/review/components.json` from the measured spec, the comp and the plate files, and refuses, naming each one, while any raster region lacks its plate. Never write or edit that file for this stage: the packet is derived from the spec, so every change belongs in the regions file. + +If the harness exposes `component_review`, call it with `manifest_path` set to `.impeccable/review/components.json`. The host captures, presents the review 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; `serve` exits 0 once they submit. Read the decisions with `.github/skills/impeccable/scripts/impeccable component-review status --session `; `.github/skills/impeccable/scripts/impeccable component-review verify --manifest .impeccable/review/components.json` confirms approval and refuses pending, needs-work and stale input. Never submit the page or write a receipt on the user's behalf. + +`serve` exits 2 when this session has no browser (the same signal as the decision page) and 4 when it closes after 30 idle minutes without a decision. Either way no one is reviewing: stop waiting, do not approve anything yourself, and do not build past this checkpoint. End the run and report the plan and asset review as pending with its session ID, so the user can resume it. A waiting review is pending work, not a completed build. + +## Act on the receipt + +Apply the user's decisions as given, never your own favorable verdict in their place. + +- **approve**: once every item is approved and the inventory is confirmed, advance to the hero. +- **revise** (a plate): regenerate that plate at the same path with the user's feedback. +- **revise with split** (an asset): replace that region in the regions file with its layers: a frame plate with a transparent opening (kind `plate`, same box), the content as its own `image` region at the opening's box, and each moving part (a shutter, a door) as its own plate. Name each layer after the original region, as `-frame`, `-view` or `-shutter-left`, so the next round shows it as part of the user's request. Rerun `.github/skills/impeccable/scripts/impeccable comp-spec --comp --regions ` and produce the plates. +- **revise** (a plan item): change the regions file as the feedback says (resize or extend a raster region, split material into its own plate region, or adjust the code region), rerun `.github/skills/impeccable/scripts/impeccable comp-spec --comp --regions `, and produce any new plates. +- **reclassify** (a code region): in the regions file, change that region's `kind` to the one the user chose and rewrite its `note` to describe the material. Rerun `.github/skills/impeccable/scripts/impeccable comp-spec --comp --regions `, then produce the new plates, with the asset producer when subagents are available. +- **missing**: add the region to the regions file, rerun `.github/skills/impeccable/scripts/impeccable comp-spec --comp --regions `, and produce its plate if it is raster. + +Then run `plan`, `capture` and `serve` again. Unchanged decisions carry over, so the user sees only what changed. Any spec change, or a plate replaced after acceptance, needs a new round; the build-phase gate stays closed until the review of the current spec is accepted. + +## Assemble and review + +Build the first viewport from the approved plates and plan, and run the hero gate. Human review does not waive its integrity checks. After three failed hero attempts, or three failed responsive attempts before any first viewport is accepted, stop iterating and present the first-viewport review with the current build; the user's eye settles what the readings could not. + +Present a second manifest at `.impeccable/review/hero.json`. You write this one, and its shape is fixed: + +```json +{ + "schemaVersion": 2, + "stage": "hero", + "id": "hero", + "title": "First viewport", + "comp": {"path": ".impeccable/mocks/comp.png", "width": 1536, "height": 1024}, + "components": [{ + "id": "first-viewport", + "name": "First viewport", + "medium": "HTML / CSS", + "note": "Assembled first viewport", + "box": {"x": 0, "y": 0, "w": 1, "h": 1}, + "preview": {"kind": "page", "path": "index.html"}, + "dependencies": ["styles.css", "assets/plates/sky.png", "fonts/display.woff2"] + }] +} +``` + +- `schemaVersion` is 2. Version 3 is the plan review packet and requires stage `components`. `codeRegions` and `specSha256` exist only in version 3 and are refused here. `reviewGroup` is refused too: version 3 rejects it, and any other version accepts it only on a page preview in a `components`-stage manifest, never in a hero manifest. +- `comp.path` is the `comp` value in `.impeccable/build/spec.json`, and `width` and `height` are that PNG's pixel size. +- Exactly one component, with `box` exactly `{"x": 0, "y": 0, "w": 1, "h": 1}` and `name`, `medium` and `note` as strings. +- `preview.path` is the page entry the build gates (`artifact` in `.impeccable/build/state.json`). +- `dependencies` lists every other file the page loads (stylesheets, scripts, plates, images, fonts) as bare strings. Every path, here and above, is project-relative and plain: no leading `./` or `/`, no `..`, no URL, no `?`, `#`, `%`, `:` or backslash. A request to a file missing from this list, or to another host, fails the capture. + +The reference stays the approved comp. Call the same host review tool, or run `capture`, `serve` and `verify` with this manifest. Needs-work feedback starts another assembly round. + +Acceptance closes human review for this build: never request plan, asset or assembly approval again. While the page renders what the user accepted, the hero score, the palette check and every numeric reading are advisories; material vetoes still hold (a missing or unreferenced plate, an SVG illustration, an organic clip, a clipped plate, invented ink, failed rendered presence). A text, control or chrome region the accepted screenshot also lacks is settled by the acceptance; a plate it lacks is a question for the user that the gate's reason spells out: do not move off what they accepted on your own, but carry out their answer, including placing the plate. When the capture no longer matches the accepted screenshot, restore what the user accepted; until then the readings apply. Complete the rest of the page, responsive behavior, finish checks and documentation with the accepted first viewport as the visual direction. This is first-viewport calibration, not a claim that the user reviewed the rest of the page. Shared stylesheet edits do not reopen approval. Preserve the accepted direction; a later explicit user change is a new task. + +Assembled-page capture executes inline and declared local scripts from the pinned inputs. Network APIs, frames and workers are unavailable; the initial viewport must settle before capture. Keep the real page and declare its scripts rather than removing behavior to pass review. diff --git a/.github/skills/impeccable/reference/craft-floor.md b/.github/skills/impeccable/reference/craft-floor.md index 408f291..1f2bb82 100644 --- a/.github/skills/impeccable/reference/craft-floor.md +++ b/.github/skills/impeccable/reference/craft-floor.md @@ -1,10 +1,10 @@ # Craft floor -Load this after the direction is settled, and build without announcing the checklist. A pinned brief or the committed visual world overrides anything here; your own habit does not. When the design hook is active it already enforces the mechanical checks below as you edit: act on its findings instead of re-auditing each rule. +Load this after the direction is settled, and build without announcing the checklist. A pinned brief or the committed visual world overrides anything here except a line marked as a ban; your own habit does not. When the design hook is active it already enforces the mechanical checks below as you edit: act on its findings instead of re-auditing each rule. ## Verify -Each of these is a check on the built result, not an intention. Run them together in the batched inspection rounds, not as separate screenshot trips; the checks share one render. +Each of these is a check on the built result, not an intention. Run them together in each batched look at the render, not as separate screenshot trips; the checks share one render. - **Contrast:** body and placeholder text ≥4.5:1, large text ≥3:1. On colored surfaces tint secondary text from that hue or the foreground; never gray. - **Depth:** shadows carry an offset and a soft blur. A zero-offset colored halo is decoration. @@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe - **Type:** body measure 65–75ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows. - **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth. - **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus. +- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably. - **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery. - **Coverage:** every brief requirement present and findable within seconds. @@ -37,6 +38,7 @@ Surface habits: - Monospace as a costume for "technical" rather than for code, data, or measurement. - A system display face (Impact, Arial Black, the platform sans) as the display voice of an own-world page. Source and self-host a face whose character matches the approved lettering; the closest installed font is a failure, not a fallback. - Unicode glyphs or emoji standing in for an icon system. Icons are drawn, from a real library or authored SVG, in one consistent stroke and weight. +- Geometric masks standing in for organic contours. A circle, polygon, or radial-gradient cutout approximating a photographic subject's edge is the cheap version of the effect and reads worse than omitting it. Derive an alpha matte from the actual image, or produce a cut-out asset. - Light or dark picked by category. Pick it from the use scene: who, where, under what ambient light. The floor holds the mechanics; it never picks the direction. With every check green, spend the page on the committed world, and when torn between refined and committed, commit. diff --git a/.github/skills/impeccable/reference/critique.md b/.github/skills/impeccable/reference/critique.md index a4256e3..e60e673 100644 --- a/.github/skills/impeccable/reference/critique.md +++ b/.github/skills/impeccable/reference/critique.md @@ -1,17 +1,19 @@ ### Purpose -Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands. +Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run. ### Hard Invariants -- Assessment A (design review) and Assessment B (detector/browser evidence) are both required. -- Assessment A and B MUST run as two isolated sub-agents whenever a sub-agent/Task tool is exposed. Running them inline in this context is "possible" but is NOT permitted; it is a degraded run. Inline is allowed ONLY when no sub-agent tool exists (or the user declined, on harnesses that ask). +- Assessment A (design review) and Assessment B (detector/browser evidence) are both required, except on a native platform (see Assessment Orchestration). +- Assessment A and B MUST run as two isolated sub-agents whenever a sub-agent/Task tool is exposed. Running them inline in this context is "possible" but is NOT permitted; it is a degraded run. Inline is allowed ONLY when no sub-agent tool exists (or the user declined, on harnesses that ask). Native platforms run A alone (see Assessment Orchestration). - If you degrade for any reason, the report's first line MUST be a banner: `⚠️ DEGRADED: single-context ()`. A silent degraded critique is a failed critique. - Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment. -- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt. +- A skipped detector is a failed critique run unless `impeccable detect` is missing or crashes after a real attempt, or the platform is native (see Assessment Orchestration). - Viewable targets require browser inspection when available. - Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it. - Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page. +- The question is the LAST thing in the response. Write the entire report out first, then ask; nothing follows the question. Prose emitted after a structured question is withheld until the user answers it, so a report written after the question reads as if the critique never ran. +- A run that ends with neither the targeted questions nor a literal `Questions skipped: ` line is an incomplete run. The report is not the finish; the close is. ### Setup @@ -21,22 +23,25 @@ Resolve one stable target, run two independent assessments, synthesize a design - "this page" -> the current URL or source file 2. **Confirm the target slugs cleanly**: ```bash - node .github/skills/impeccable/scripts/critique-storage.mjs slug "" + .github/skills/impeccable/scripts/impeccable critique-storage slug "" ``` Every later command also accepts the resolved target directly and derives the same slug internally; never hand-write a slug. If this exits non-zero, skip persistence and trend for this run, but continue the critique. 3. **Read `.impeccable/critique/ignore.md`** if it exists. Drop matching findings silently; it is the only prior-run input critique consumes. ### Assessment Orchestration +**Native platforms** (`ios` / `android` / `adaptive`): Assessment B does not apply, because `impeccable detect` and the browser overlay read only web markup. Run Assessment A alone in this context and skip both sub-agent gates below, the Codex permission question included: with no B there is nothing to isolate A from, so this is the complete native method and carries no degraded banner. Do not run `impeccable detect` on native source; its empty result reads as clean. + Delegate Assessment A and Assessment B to separate sub-agents. They must not see each other's output. Do not show findings to the user until synthesis. Sub-agent gate (all harnesses): - Unless a harness-specific gate below overrides this, spawn A and B as two isolated, parallel sub-agents whenever a sub-agent/Task tool is exposed. This is the default and is mandatory; do not run them inline because it is faster. +- Desktop apps (see **Desktop apps** under Assessment B) are the one exception to parallel: both sub-agents would drive the app's single window at once. Seed the window, spawn A, wait until it has finished and put the window back, then spawn B. They are still two isolated sub-agents, so this is not the degraded path. - "Unavailable" means exactly one thing: no sub-agent/Task tool is exposed in this session (or, on harnesses that ask, the user declined). It does not mean inconvenient. - If and only if sub-agents are unavailable, fall back sequentially: finish and record Assessment A, then run Assessment B, then synthesize, and emit the degraded banner. - Whichever path you take, declare it in the report header (see Report header provenance). Skipping sub-agents without the banner is the most common failure of this command. -If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL. +If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL. Desktop apps are the exception (see **Desktop apps** under Assessment B). ### Assessment A: Design Review @@ -53,11 +58,11 @@ Return: design-specificity verdict, heuristic scores, cognitive load, emotional ### Assessment B: Detector + Browser Evidence -Run the bundled detector and browser visualization evidence. Assessment B is mandatory and must remain isolated from Assessment A until both are complete. +Run the bundled detector and browser visualization evidence. On web, Assessment B is mandatory and must remain isolated from Assessment A until both are complete. CLI scan: ```bash -node .github/skills/impeccable/scripts/detect.mjs --json [target] +.github/skills/impeccable/scripts/impeccable detect --json [target] ``` - Pass markup files/directories as `[target]`; do not pass CSS-only files. @@ -71,18 +76,27 @@ Browser visualization is required for a viewable target when browser automation 1. Create a fresh tab and navigate. Prefer the harness's native/browser-canvas screenshot path before hand-rolling a Playwright/Puppeteer script; only fall back to a custom script when no native browser tool is exposed. 2. Preflight mutable injection by setting `document.title` and appending a `\n' + - open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' - ); -} - -function detectLineEnding(content) { - if (content.includes('\r\n')) return '\r\n'; - if (content.includes('\r')) return '\r'; - return '\n'; -} - -function normalizeLineEndings(content, lineEnding) { - return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); -} - -function readLineEndingAt(content, index) { - if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; - if (content[index] === '\n') return '\n'; - if (content[index] === '\r') return '\r'; - return ''; -} - -export function insertTag(content, config, port, token, scriptAttrs = '') { - const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, token, scriptAttrs), lineEnding); - // insertBefore: match the LAST occurrence. Anchors like `` naturally - // belong at the end, and the same literal can appear earlier in code blocks - // within rendered documentation pages. - if (config.insertBefore) { - const idx = content.lastIndexOf(config.insertBefore); - if (idx === -1) return content; - return content.slice(0, idx) + block + content.slice(idx); - } - // insertAfter: match the FIRST occurrence — typical anchors like `` or - // `` open near the top of the document. - const idx = content.indexOf(config.insertAfter); - if (idx === -1) return content; - const after = idx + config.insertAfter.length; - // Preserve an existing trailing newline if the anchor already has one. - // Slice the remainder from the original anchor offset, not prefix.length: - // in the no-newline case prefix is one char longer than the anchor (the - // appended '\n'), so slicing by prefix.length would drop the first real - // character after the anchor (#227). - const existingNewline = readLineEndingAt(content, after); - const prefix = content.slice(0, after) + (existingNewline || lineEnding); - const rest = content.slice(after + existingNewline.length); - return prefix + block + rest; -} - -/** - * Remove the live script block. Matches either HTML or JSX comment markers - * regardless of config (so stale tags from a wrong config can still be cleaned). - * - * Indent-preserving: captures any whitespace immediately preceding the opener - * marker and re-emits it in place of the removed block. `insertTag` inserted - * the block *after* the original line's indent and *before* the anchor (e.g. - * ``), which moved the indent onto the opener line and left the anchor - * unindented. Replacing the whole block (plus its trailing newline) with just - * the captured indent hands the indent back to the anchor that follows. - */ -export function removeTag(content, _syntax) { - const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, - ]; - for (const pat of patterns) { - let changed = false; - let next = content; - do { - content = next; - next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (/[\r\n]/.test(trailing)) return leadingIndent; - return leadingIndent || trailing || ''; - }); - if (next !== content) changed = true; - } while (next !== content); - if (changed) return next; - } - return content; -} - -// --------------------------------------------------------------------------- -// Content-Security-Policy meta-tag patcher -// -// When the user's HTML carries ``, -// the cross-origin load of /live.js (and the SSE/POST connection back to -// localhost:PORT) is blocked unless the CSP explicitly allows that origin. -// -// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`, -// and stash the original `content` value in a `data-impeccable-csp-original` -// attribute (base64) so revert is exact. -// -// On remove: detect the marker attribute, decode it, restore the original -// content value verbatim, drop the marker. -// -// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp, -// shared helpers) is NOT patched here — those need framework-specific config -// edits and are handled via the existing detect-csp.mjs reference output. -// Only the in-source meta-tag form gets the auto-patch. -// --------------------------------------------------------------------------- - -const CSP_MARKER_ATTR = 'data-impeccable-csp-original'; - -function findCspMetaTags(content) { - const out = []; - const tagRe = /]*?)\/?>/gis; - let m; - while ((m = tagRe.exec(content)) !== null) { - const attrs = m[1]; - if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue; - out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs }); - } - return out; -} - -function getAttr(attrs, name) { - const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i'); - const m = attrs.match(re); - return m ? { quote: m[1], value: m[2], full: m[0] } : null; -} - -function appendOriginToDirective(csp, directive, origin) { - const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i'); - const m = csp.match(re); - if (m) { - const tokens = m[4].trim().split(/\s+/); - if (tokens.includes(origin)) return csp; - return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`); - } - // Directive missing — add it. Use 'self' + origin so we don't inadvertently - // narrow the policy compared to the default-src fallback (most users with - // an explicit CSP have 'self' there). - return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`; -} - -export function patchCspMeta(content, port) { - const tags = findCspMetaTags(content); - if (tags.length === 0) return content; - const origin = `http://localhost:${port}`; - - // Walk last-to-first so prior splices don't invalidate later indices. - let result = content; - for (let i = tags.length - 1; i >= 0; i--) { - const tag = tags[i]; - const attrs = tag.attrs; - if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched - const contentAttr = getAttr(attrs, 'content'); - if (!contentAttr) continue; - - const original = contentAttr.value; - let patched = original; - patched = appendOriginToDirective(patched, 'script-src', origin); - patched = appendOriginToDirective(patched, 'connect-src', origin); - // The shader overlay during 'generating' creates a screenshot via - // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects - // those. Add `blob:` so the overlay doesn't throw a CSP violation. - patched = appendOriginToDirective(patched, 'img-src', 'blob:'); - if (patched === original) continue; - - const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`; - const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`; - // The tagRe captures any whitespace between the last attribute and the - // closing `/>` as part of `attrs`. Naively appending ` ${marker}` after - // a replace would land it BEFORE that trailing space, leaving a double - // space inside attrs and clobbering the space before `/>`. Split off - // the trailing whitespace, splice the marker into the attribute body, - // and re-append the original trailing whitespace so a self-closing - // `` round-trips byte-for-byte. - const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0]; - const attrsBody = attrs.slice(0, attrs.length - trailingWs.length); - const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs; - const newTag = tag.full.replace(attrs, newAttrs); - - result = result.slice(0, tag.start) + newTag + result.slice(tag.end); - } - return result; -} - -export function revertCspMeta(content) { - const tags = findCspMetaTags(content); - if (tags.length === 0) return content; - - let result = content; - for (let i = tags.length - 1; i >= 0; i--) { - const tag = tags[i]; - const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR); - if (!origAttr) continue; - const contentAttr = getAttr(tag.attrs, 'content'); - if (!contentAttr) continue; - - let originalValue; - try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); } - catch { continue; } - - const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`; - let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr); - // Drop the marker attribute and any single space immediately preceding it. - newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), ''); - const newTag = tag.full.replace(tag.attrs, newAttrs); - - result = result.slice(0, tag.start) + newTag + result.slice(tag.end); - } - return result; -} - -/** The journal's undo for a tag-strategy patch: drop the block, restore CSP. */ -export function unpatchTagFile(content) { - return revertCspMeta(removeTag(content)); -} diff --git a/.github/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs b/.github/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs deleted file mode 100644 index 9bfb3db..0000000 --- a/.github/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs +++ /dev/null @@ -1,70 +0,0 @@ -/** - * TanStack Start registry entry. - * - * Detection and the apply/remove pair are the existing adapter's - * (`../tanstack-adapter.mjs`); this file only declares them to the registry - * and names the artifacts the journal has to be able to heal. - */ - -import { - TANSTACK_MARKER_OPEN, - applyTanStackLiveAdapter, - detectTanStackStartProject, - removeTanStackLiveAdapter, - unpatchTanStackRoot, -} from '../tanstack-adapter.mjs'; - -export const tanstackStart = { - name: 'tanstack-start', - - detect(cwd) { - return detectTanStackStartProject(cwd); - }, - - inject: { - kind: 'adapter', - - apply({ cwd, port, token, project }) { - return applyTanStackLiveAdapter({ cwd, port, token, project }); - }, - - remove({ cwd, project }) { - return removeTanStackLiveAdapter({ cwd, project }); - }, - - // The mount component's extension follows the root route's, so the path - // cannot live in the static ignore list. - ignorePatterns(project) { - return project?.componentFile ? [project.componentFile] : []; - }, - - artifacts({ project }) { - if (!project) return []; - return [ - { - kind: 'created', - path: project.componentFile, - marker: 'impeccable-live-tanstack', - pruneTo: 'src', - }, - { - kind: 'patched', - path: project.rootRoute, - patch: 'tanstack-root', - markers: [TANSTACK_MARKER_OPEN], - }, - ]; - }, - - unpatch: { - 'tanstack-root': unpatchTanStackRoot, - }, - }, - - source: { - extensions: ['.tsx', '.jsx'], - preview: 'source', - styleMode: 'scoped', - commentSyntax: 'jsx', - }, -}; diff --git a/.github/skills/impeccable/scripts/live/frameworks/vite-generic.mjs b/.github/skills/impeccable/scripts/live/frameworks/vite-generic.mjs deleted file mode 100644 index 4713670..0000000 --- a/.github/skills/impeccable/scripts/live/frameworks/vite-generic.mjs +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Generic Vite registry entry: a bundled app with a real `index.html` entry - * and no framework-specific document ownership. React, Vue, Solid, Preact and - * a plain TanStack Router SPA all land here — the marker-wrapped script block - * goes straight into the HTML entry. - * - * This is the entry that catches everything with a bundler config; only - * static-html sits below it. - */ - -import { fileExists, findConfigFile, hasAnyDependency } from './detect-utils.mjs'; - -const VITE_CONFIG_RE = /^vite\.config\.(?:js|mjs|cjs|ts|mts|cts)$/; - -export function detectViteProject(cwd = process.cwd()) { - const configFile = findConfigFile(cwd, VITE_CONFIG_RE); - if (configFile) return { configFile, via: 'config' }; - if (hasAnyDependency(cwd, ['vite'])) return { configFile: null, via: 'package' }; - // A zero-config Vite app is index.html + package.json, the same pair - // roots.mjs treats as an app root. - if (fileExists(cwd, 'index.html') && fileExists(cwd, 'package.json')) { - return { configFile: null, via: 'zero-config' }; - } - return null; -} - -export const viteGeneric = { - name: 'vite-generic', - - detect(cwd) { - return detectViteProject(cwd); - }, - - inject: { kind: 'tag' }, - - source: { - extensions: ['.tsx', '.jsx'], - preview: 'source', - styleMode: 'scoped', - commentSyntax: 'jsx', - }, -}; diff --git a/.github/skills/impeccable/scripts/live/generation-preflight.mjs b/.github/skills/impeccable/scripts/live/generation-preflight.mjs deleted file mode 100644 index bfe81b3..0000000 --- a/.github/skills/impeccable/scripts/live/generation-preflight.mjs +++ /dev/null @@ -1,149 +0,0 @@ -import { execFile } from 'node:child_process'; -import path from 'node:path'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); -const PREFLIGHT_TIMEOUT_MS = 15_000; - -// Per-target cache of the resolved source file. The wrap search walks the whole -// project tree and was measured at ~7.6s on a large repo; it re-ran on every -// generate for the same picked element (re-rolls, param passes). Keyed by the -// target signature (locator + route), so it invalidates automatically when the -// element or route changes; a failed resolution evicts its entry (see below). -const sourceResolutionCache = new Map(); - -/** Test/lifecycle hook: drop all cached source resolutions. */ -export function clearSourceResolutionCache() { - sourceResolutionCache.clear(); -} - -function targetSignature(event) { - const isInsert = event.mode === 'insert'; - const target = isInsert ? insertTarget(event) : replaceTarget(event); - return JSON.stringify({ - mode: isInsert ? 'insert' : 'replace', - position: isInsert ? target.position : null, - elementId: target.elementId || null, - classes: target.classes || null, - tag: target.tag || null, - pageUrl: event.pageUrl || null, - }); -} - -export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) { - if (!event || event.type !== 'generate' || !event.id) return null; - - const isInsert = event.mode === 'insert'; - const target = isInsert ? insertTarget(event) : replaceTarget(event); - if (!target.elementId && !target.classes) return null; - - const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs'); - const args = [script, '--id', event.id, '--count', String(event.count || 3)]; - // Compute the scaffold but do not write it into source for source-preview - // targets. The agent writes wrapper + variants atomically; a premature - // server-side write reloads the framework and strands the browser at 0/N. - // No-op on the svelte-component path, which never writes the route source. - args.push('--defer-source-write'); - if (isInsert) args.push('--position', target.position); - if (target.elementId) args.push('--element-id', target.elementId); - if (target.classes) args.push('--classes', target.classes); - if (target.tag) args.push('--tag', target.tag); - if (target.text) args.push('--text', target.text); - if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl); - const signature = targetSignature(event); - // A cached resolution points the helper straight at the file, skipping the - // tree search. The helper still reads current content, so line ranges stay - // fresh; only discovery is cached. - const cachedFile = cache ? cache.get(signature) : null; - if (cachedFile) args.push('--file', cachedFile); - return { script, args, mode: isInsert ? 'insert' : 'replace', signature }; -} - -/** - * Scaffold the source for a generate event before handing it to an agent. - * - * Async on purpose. This spawns `live-wrap.mjs`, which walks the project's - * source tree and can take seconds (measured at ~7.6s on a large repo when the - * element is not found, with a 15s ceiling). The live server is single-threaded - * and calls this while leasing a poll, so a synchronous spawn froze the whole - * server for that entire window: Accept and Discard POSTs, SSE progress - * broadcasts, and every other poll stalled behind it. - */ -export async function runGenerationPreflight(event, { - cwd = process.cwd(), - scriptsDir, - execFileImpl = execFileAsync, - timeoutMs = PREFLIGHT_TIMEOUT_MS, - cache = sourceResolutionCache, -} = {}) { - const command = buildGenerationPreflight(event, scriptsDir, { cache }); - if (!command) { - return { ok: false, skipped: true, reason: 'insufficient_locator' }; - } - - const startedAt = performance.now(); - try { - const { stdout } = await execFileImpl(process.execPath, command.args, { - cwd, - encoding: 'utf-8', - timeout: timeoutMs, - }); - const line = String(stdout).trim().split('\n').filter(Boolean).pop(); - if (!line) throw new Error('preflight returned no scaffold metadata'); - const scaffold = JSON.parse(line); - // Cache the resolved SOURCE file (route source, not the svelte manifest) so - // the next generate on this target skips the tree search. - const resolvedSource = scaffold.sourceFile || scaffold.file; - if (cache && command.signature && typeof resolvedSource === 'string') { - cache.set(command.signature, resolvedSource); - } - return { - ok: true, - mode: command.mode, - durationMs: performance.now() - startedAt, - scaffold, - }; - } catch (error) { - // Evict a stale/failed resolution so the next attempt does a full search - // (the element may have moved out of the previously cached file). - if (cache && command.signature) cache.delete(command.signature); - return { - ok: false, - mode: command.mode, - durationMs: performance.now() - startedAt, - error: compactError(error), - }; - } -} - -function replaceTarget(event) { - return normalizeTarget(event.element || {}); -} - -function insertTarget(event) { - return { - ...normalizeTarget(event.insert?.anchor || {}), - position: event.insert?.position === 'before' ? 'before' : 'after', - }; -} - -function normalizeTarget(target) { - const classes = Array.isArray(target.classes) - ? target.classes.join(' ') - : String(target.classes || '').trim(); - const text = typeof target.textContent === 'string' - ? target.textContent.trim().slice(0, 80) - : ''; - return { - elementId: target.id || target.elementId || undefined, - classes: classes || undefined, - tag: target.tagName || target.tag || undefined, - text: text || undefined, - }; -} - -function compactError(error) { - const stderr = error?.stderr ? String(error.stderr).trim() : ''; - const message = stderr.split('\n').filter(Boolean).pop() || error?.message || 'preflight failed'; - return String(message).slice(0, 500); -} diff --git a/.github/skills/impeccable/scripts/live/insert-ui.mjs b/.github/skills/impeccable/scripts/live/insert-ui.mjs deleted file mode 100644 index ae54f6f..0000000 --- a/.github/skills/impeccable/scripts/live/insert-ui.mjs +++ /dev/null @@ -1,458 +0,0 @@ -/** - * Pure helpers for live-mode insert UI (browser + tests). - * Kept separate from live-browser.js so insert logic is unit-testable. - */ - -export const PLACEHOLDER_DEFAULT_HEIGHT = 80; -export const PLACEHOLDER_MIN_HEIGHT = 48; -export const PLACEHOLDER_MIN_WIDTH = 120; - -/** @typedef {'before' | 'after'} InsertPosition */ -/** @typedef {'row' | 'column'} InsertAxis */ - -/** - * Infer sibling flow axis from a container's computed layout styles. - * @param {{ display?: string, flexDirection?: string, gridTemplateColumns?: string, gridAutoFlow?: string }} style - * @returns {InsertAxis} - */ -export function detectInsertAxisFromStyle(style) { - const display = style?.display || 'block'; - if (display.includes('flex')) { - const dir = style.flexDirection || 'row'; - return dir.startsWith('row') ? 'row' : 'column'; - } - if (display === 'grid' || display === 'inline-grid') { - const flow = style.gridAutoFlow || 'row'; - if (flow.includes('column')) return 'column'; - const cols = (style.gridTemplateColumns || '').trim(); - if (cols && cols !== 'none') { - const colCount = cols.split(/\s+/).filter(Boolean).length; - if (colCount > 1) return 'row'; - } - return 'row'; - } - return 'column'; -} - -/** - * Pick insertion side from pointer position against an anchor element box. - * @param {number} clientX - * @param {number} clientY - * @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect - * @param {InsertAxis} [axis] - * @returns {InsertPosition} - */ -export function computeInsertPosition(clientX, clientY, rect, axis = 'column') { - if (!rect) return 'after'; - if (axis === 'row') { - if (!Number.isFinite(rect.left) || !Number.isFinite(rect.width) || rect.width <= 0) return 'after'; - const mid = rect.left + rect.width / 2; - return clientX < mid ? 'before' : 'after'; - } - if (!Number.isFinite(rect.top) || !Number.isFinite(rect.height) || rect.height <= 0) return 'after'; - const mid = rect.top + rect.height / 2; - return clientY < mid ? 'before' : 'after'; -} - -/** - * Whether Create is allowed for an insert session. - * Requires a non-empty prompt OR at least one annotation. - */ -export function canCreateInsert({ prompt, comments, strokes }) { - const hasPrompt = typeof prompt === 'string' && prompt.trim().length > 0; - const hasComments = Array.isArray(comments) && comments.length > 0; - const hasStrokes = Array.isArray(strokes) && strokes.some( - (s) => Array.isArray(s?.points) && s.points.length >= 2, - ); - return hasPrompt || hasComments || hasStrokes; -} - -/** Tooltip/title when Create is disabled. */ -export function insertCreateDisabledReason({ prompt, comments, strokes }) { - if (canCreateInsert({ prompt, comments, strokes })) return null; - return 'Add a prompt or annotate the placeholder to create'; -} - -/** - * Fixed-position insert line coordinates (viewport px). - * @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect - * @param {InsertPosition} position - * @param {InsertAxis} [axis] - */ -export function insertLineCoords(rect, position, axis = 'column') { - if (axis === 'row') { - const right = rect.right ?? rect.left + rect.width; - const x = position === 'before' ? rect.left - 2 : right + 2; - return { axis: 'row', top: rect.top, left: x, width: 0, height: rect.height }; - } - const bottom = rect.bottom ?? rect.top + rect.height; - const y = position === 'before' ? rect.top - 2 : bottom + 2; - return { axis: 'column', top: y, left: rect.left, width: rect.width, height: 0 }; -} - -/** Cursor while hovering an insert boundary. */ -export function cursorForInsertAxis(axis) { - return axis === 'row' ? 'ew-resize' : 'ns-resize'; -} - -function groupSiblingRows(siblings, rowThreshold = 8) { - const sorted = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left); - const rows = []; - for (const entry of sorted) { - let placed = false; - for (const row of rows) { - if (Math.abs(entry.rect.top - row[0].rect.top) <= rowThreshold) { - row.push(entry); - placed = true; - break; - } - } - if (!placed) rows.push([entry]); - } - return rows; -} - -function horizontalOverlap(a, b) { - const left = Math.max(a.left, b.left); - const right = Math.min(a.right ?? a.left + a.width, b.right ?? b.left + b.width); - return Math.max(0, right - left); -} - -/** - * Hit-test the gap between adjacent siblings (flex rows, grid columns, stacked blocks). - * @param {number} clientX - * @param {number} clientY - * @param {Array<{ el: unknown, rect: { top: number, left: number, width: number, height: number, bottom?: number, right?: number } }>} siblings - * @param {{ slop?: number, minOverlap?: number }} [opts] - */ -export function hitSiblingInsertGap(clientX, clientY, siblings, opts = {}) { - if (!Array.isArray(siblings) || siblings.length < 2) return null; - const slop = opts.slop ?? 12; - const minOverlap = opts.minOverlap ?? 0.25; - - for (const row of groupSiblingRows(siblings)) { - if (row.length < 2) continue; - const sorted = [...row].sort((a, b) => a.rect.left - b.rect.left); - for (let i = 0; i < sorted.length - 1; i++) { - const a = sorted[i]; - const b = sorted[i + 1]; - const aRight = a.rect.right ?? a.rect.left + a.rect.width; - const bLeft = b.rect.left; - if (bLeft <= aRight) continue; - const top = Math.max(a.rect.top, b.rect.top); - const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height; - const bBottom = b.rect.bottom ?? b.rect.top + b.rect.height; - const bottom = Math.min(aBottom, bBottom); - const span = bottom - top; - const minH = Math.min(a.rect.height, b.rect.height); - if (span < minH * minOverlap) continue; - - const inX = clientX >= aRight - slop && clientX <= bLeft + slop; - const inY = clientY >= top - slop && clientY <= bottom + slop; - if (!inX || !inY) continue; - - const midX = (aRight + bLeft) / 2; - return { - anchor: b.el, - position: 'before', - axis: 'row', - line: { axis: 'row', left: midX, top, width: 0, height: span }, - }; - } - } - - const sortedCol = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left); - for (let i = 0; i < sortedCol.length - 1; i++) { - const a = sortedCol[i]; - const b = sortedCol[i + 1]; - const overlap = horizontalOverlap(a.rect, b.rect); - const minW = Math.min(a.rect.width, b.rect.width); - if (overlap < minW * minOverlap) continue; - - const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height; - const gapTop = aBottom; - const gapBottom = b.rect.top; - if (gapBottom <= gapTop) continue; - - const overlapLeft = Math.max(a.rect.left, b.rect.left); - const overlapRight = Math.min( - a.rect.right ?? a.rect.left + a.rect.width, - b.rect.right ?? b.rect.left + b.rect.width, - ); - const inY = clientY >= gapTop - slop && clientY <= gapBottom + slop; - const inX = clientX >= overlapLeft - slop && clientX <= overlapRight + slop; - if (!inY || !inX) continue; - - const midY = (gapTop + gapBottom) / 2; - return { - anchor: b.el, - position: 'before', - axis: 'column', - line: { axis: 'column', top: midY, left: overlapLeft, width: overlap, height: 0 }, - }; - } - - return null; -} - -/** - * Resolve insert hover target, side, axis, and indicator line for the pointer. - */ -export function resolveInsertHover({ clientX, clientY, target, rect, axis, siblings }) { - const gap = hitSiblingInsertGap(clientX, clientY, siblings); - if (gap) return gap; - - const position = computeInsertPosition(clientX, clientY, rect, axis); - const line = insertLineCoords(rect, position, axis); - return { anchor: target, position, axis, line }; -} - -/** - * How the in-flow placeholder should participate in layout. - * Prefer implicit sizing (flex / %) so row inserts don't inherit the full parent width in px. - * @returns {{ kind: 'flex', flex: string, minWidth: number } | { kind: 'percent' } | { kind: 'auto' } | { kind: 'explicit', width: number }} - */ -export function placeholderSizing({ axis, parentDisplay, parentWidth, anchorFlex }) { - const display = parentDisplay || 'block'; - const w = Number.isFinite(parentWidth) ? parentWidth : 0; - - if (axis === 'row') { - if (display.includes('flex')) { - const flex = anchorFlex && anchorFlex !== 'none' && anchorFlex !== '0 1 auto' - ? anchorFlex - : '1 1 0'; - return { kind: 'flex', flex, minWidth: 0 }; - } - if (display === 'grid' || display === 'inline-grid') { - return { kind: 'auto' }; - } - } - - if (w >= PLACEHOLDER_MIN_WIDTH) { - return { kind: 'percent' }; - } - - return { - kind: 'explicit', - width: Math.max(PLACEHOLDER_MIN_WIDTH, w || PLACEHOLDER_MIN_WIDTH), - }; -} - -/** Width kinds that need materializing to px before edge-resize. */ -export function placeholderWidthIsImplicit(kind) { - return kind === 'flex' || kind === 'percent' || kind === 'auto'; -} - -/** - * Clamp user-resized placeholder dimensions. - */ -export function clampPlaceholderSize(width, height, parentWidth, opts = {}) { - const minW = opts.minWidth ?? PLACEHOLDER_MIN_WIDTH; - const minH = opts.minHeight ?? PLACEHOLDER_MIN_HEIGHT; - const maxW = opts.maxWidth ?? Math.max(minW, parentWidth || minW); - return { - width: Math.min(maxW, Math.max(minW, Math.round(width))), - height: Math.max(minH, Math.round(height)), - }; -} - -/** CSS cursor for a placeholder edge resize handle. */ -export function cursorForPlaceholderEdge(edge) { - if (edge === 'n' || edge === 's') return 'ns-resize'; - if (edge === 'e' || edge === 'w') return 'ew-resize'; - return 'default'; -} - -/** - * Compute placeholder box after dragging one edge (in-flow margins shift for n/w). - * @param {{ width: number, height: number, marginLeft?: number, marginTop?: number }} start - * @param {'n'|'e'|'s'|'w'} edge - * @param {number} dx pointer delta X since drag start - * @param {number} dy pointer delta Y since drag start - * @param {number} parentWidth - */ -export function resizePlaceholderFromEdge(start, edge, dx, dy, parentWidth, opts = {}) { - const base = { - width: start.width, - height: start.height, - marginLeft: start.marginLeft ?? 0, - marginTop: start.marginTop ?? 0, - }; - if (edge === 'e') base.width = start.width + dx; - else if (edge === 'w') { - base.width = start.width - dx; - base.marginLeft = start.marginLeft + dx; - } else if (edge === 's') base.height = start.height + dy; - else if (edge === 'n') { - base.height = start.height - dy; - base.marginTop = start.marginTop + dy; - } - - const clamped = clampPlaceholderSize(base.width, base.height, parentWidth, opts); - if (edge === 'w') { - base.marginLeft = start.marginLeft + start.width - clamped.width; - } else if (edge === 'n') { - base.marginTop = start.marginTop + start.height - clamped.height; - } - - return { - width: clamped.width, - height: clamped.height, - marginLeft: Math.round(base.marginLeft), - marginTop: Math.round(base.marginTop), - }; -} - -/** Pick and insert toggles are independent but turning one ON turns the other OFF. */ -export function applyPickToggle(pickActive, insertActive) { - const nextPick = !pickActive; - return { - pickActive: nextPick, - insertActive: nextPick ? false : insertActive, - }; -} - -export function applyInsertToggle(pickActive, insertActive) { - const nextInsert = !insertActive; - return { - pickActive: nextInsert ? false : pickActive, - insertActive: nextInsert, - }; -} - -/** - * Build the browser generate payload for insert mode. - */ -export function buildInsertGeneratePayload({ - id, - count, - pageUrl, - anchorContext, - position, - placeholder, - freeformPrompt, - comments, - strokes, - screenshotPath, -}) { - const payload = { - type: 'generate', - mode: 'insert', - id, - count, - pageUrl, - insert: { - position, - anchor: anchorContext, - }, - placeholder, - freeformPrompt: freeformPrompt?.trim() || undefined, - }; - if (comments?.length) payload.comments = comments; - if (strokes?.length) payload.strokes = strokes; - if (screenshotPath) payload.screenshotPath = screenshotPath; - return payload; -} - -/** - * Whether a variant wrapper is currently shown (handles `hidden` and display:none). - * @param {{ hidden?: boolean, style?: { display?: string } } | null | undefined} el - */ -export function isVariantShown(el) { - if (!el) return false; - if (el.hidden) return false; - if (el.style?.display === 'none') return false; - return true; -} - -/** - * Show or hide a variant wrapper for cycling. - * @param {{ hidden?: boolean, style?: { display?: string }, removeAttribute?: (name: string) => void, setAttribute?: (name: string, value?: string) => void } | null | undefined} el - * @param {boolean} shown - */ -export function setVariantShown(el, shown) { - if (!el) return; - if (shown) { - el.removeAttribute?.('hidden'); - if (el.style) el.style.display = ''; - } else { - el.setAttribute?.('hidden', ''); - if (el.style) el.style.display = 'none'; - } -} - -/** - * Pick the best live anchor during an insert session (placeholder until variants land). - * @param {{ - * wrapper?: unknown, - * variantCount?: number, - * visibleVariant?: number, - * placeholder?: unknown, - * insertAnchor?: unknown, - * pickVariantContent?: (wrapper: unknown, index: number) => unknown, - * }} opts - */ -export function resolveInsertSessionAnchor(opts) { - const { - wrapper, - variantCount = 0, - visibleVariant = 0, - placeholder, - insertAnchor, - pickVariantContent, - } = opts || {}; - if (wrapper && variantCount > 0 && visibleVariant > 0 && pickVariantContent) { - const vis = pickVariantContent(wrapper, visibleVariant); - if (vis) return vis; - } - return placeholder || insertAnchor || null; -} - -/** - * Snapshot placeholder geometry + anchor fingerprint so HMR can recreate the box. - * @param {{ - * tagName?: string, - * className?: string, - * textContent?: string, - * }} anchor - * @param {{ - * offsetWidth?: number, - * offsetHeight?: number, - * style?: { marginLeft?: string, marginTop?: string }, - * }} placeholder - * @param {{ position: 'before' | 'after', layoutAxis?: 'row' | 'column' }} meta - */ -export function buildInsertPlaceholderSnapshot(anchor, placeholder, { position, layoutAxis }) { - return { - width: Math.round(placeholder.offsetWidth || 0), - height: Math.round(placeholder.offsetHeight || PLACEHOLDER_DEFAULT_HEIGHT), - marginLeft: parseFloat(placeholder.style?.marginLeft || '') || 0, - marginTop: parseFloat(placeholder.style?.marginTop || '') || 0, - position, - layoutAxis: layoutAxis || 'column', - anchorTag: anchor.tagName || 'DIV', - anchorClasses: anchor.className || '', - anchorText: (anchor.textContent || '').trim().slice(0, 120), - }; -} - -/** - * Re-find an insert anchor after framework HMR replaced the live DOM node. - * @param {Pick} doc - * @param {ReturnType | null | undefined} snapshot - * @param {Element | null | undefined} liveAnchor - */ -export function findInsertAnchorInDom(doc, snapshot, liveAnchor = null) { - if (liveAnchor && doc.body.contains(liveAnchor)) return liveAnchor; - if (!snapshot) return null; - const tag = (snapshot.anchorTag || 'div').toLowerCase(); - const cls = (snapshot.anchorClasses || '').split(/\s+/).filter(Boolean)[0]; - const needle = snapshot.anchorText || ''; - const sel = cls ? `${tag}.${cls}` : tag; - const candidates = doc.querySelectorAll(sel); - for (const candidate of candidates) { - if (needle && !(candidate.textContent || '').includes(needle.slice(0, 40))) continue; - return candidate; - } - return null; -} diff --git a/.github/skills/impeccable/scripts/live/instructions.mjs b/.github/skills/impeccable/scripts/live/instructions.mjs deleted file mode 100644 index 19f6a1a..0000000 --- a/.github/skills/impeccable/scripts/live/instructions.mjs +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Just-in-time agent instructions for live mode. - * - * The live scripts, not the reference doc, own situational plumbing: every - * event printed by live-poll carries an `_instructions` string describing - * exactly what to do NEXT, with real ids, paths, and line numbers already - * substituted and only the active path's rules included (a svelte-component - * session never sees JSX guidance, and vice versa). live.md stays lean: the - * session contract, harness policy, and design-quality guidance that is not - * situational (identity lock, variation axes, parameter budgets). - * - * Keep these strings imperative, concrete, and short. They are read by an - * agent mid-session; every sentence must earn its tokens. Instructions are - * versioned with the scripts, so they cannot drift from behavior the way a - * hand-maintained doc can. - */ - -const PLAN_POINTER = 'Plan per live.md section 4: extract the identity lock, pick default vs departure mode, commit each variant to a DIFFERENT primary axis, squint-test the trio. Size parameter knobs per section 7 budgets.'; - -function pollCmd(scriptsPath) { - return `node ${scriptsPath}/live-poll.mjs`; -} - -function replyCmd(scriptsPath, id, rest) { - return `${pollCmd(scriptsPath)} --reply ${id} ${rest}`; -} - -export function instructionsForEvent(event, { scriptsPath = '{{scripts_path}}' } = {}) { - if (!event || typeof event !== 'object') return undefined; - switch (event.type) { - case 'generate': - return generateInstructions(event, scriptsPath); - case 'steer': - return `Do what the message asks (page edits, navigation help, or a short answer). Then reply exactly once: ${replyCmd(scriptsPath, event.id, 'steer_done ["optional short toast"]')} (on failure: --reply ${event.id} error "Short reason"). No pickup ack; poll again immediately after.`; - case 'prefetch': - return `Speculative pre-read, no reply owed: resolve ${JSON.stringify(event.pageUrl || '/')} to its source file (root "/" is usually the boot's pageFile; multi-page sites map /foo to public/foo/index.html; SPAs map all routes to one entry), read it into context, then poll again. Skip if you cannot resolve it confidently.`; - case 'variant_mount_failed': - return `The browser could NOT render variant ${event.variant}${event.url ? ` (module: ${event.url})` : ''}${event.error ? `: ${String(event.error).slice(0, 200)}` : ''}. The user sees a persistent error card, not variants. Fix the variant source files, then reply ${replyCmd(scriptsPath, event.id, 'done --file ')}; the browser retries on its own. Poll again after the reply.`; - case 'accept': - return acceptInstructions(event, scriptsPath); - case 'discard': - return event?._completionAck?.ok === true - ? 'Original restored and durable completion acknowledged; nothing to do. Poll again.' - : `Completion was not acknowledged: run node ${scriptsPath}/live-complete.mjs --id ${event.id} --discarded, then poll again.`; - case 'manual_edit_apply': - return `The user already clicked Apply; never ask, discard, or redirect. Delegate the source edits to the impeccable_manual_edit_applier subagent when available (pass cwd, scripts path, event id, page URL, chunk/deadline, batch, evidencePath); it must not poll or reply. ${event.repair ? 'A `repair` payload is present: the previous Apply changed source but validation failed; fix the CURRENT source, never roll back yourself. ' : ''}Reply exactly once: ${replyCmd(scriptsPath, event.id, `done --data '{"status":"done","appliedEntryIds":[...],"failed":[],"files":[...],"notes":[]}'`)} (status "partial"/"error" with failed[] when not every entry applied). Then poll again.`; - case 'timeout': - return 'No event arrived; poll again immediately.'; - case 'exit': - return `Session over: kill any background poll, then node ${scriptsPath}/live-server.mjs stop (removes the injected script tag). Sweep leftover impeccable-variants-start / impeccable-carbonize-start markers from source.`; - default: - return undefined; - } -} - -function generateInstructions(event, scriptsPath) { - const id = event.id; - const scaffold = event.scaffold; - const steps = []; - - if (event.screenshotPath) { - steps.push(`Read the annotated screenshot first: ${event.screenshotPath}. Comment {x,y} positions bind text to the child under that point; strokes read by shape (loop = emphasis on this thing, arrow = direction, cross = delete).`); - } else { - steps.push('No screenshot was sent (the user did not annotate); do not ask for one and do not screenshot the page. Work from element.outerHTML, the computed styles, and the prompt.'); - } - - if (event.mode === 'insert') { - steps.push(insertScaffoldInstructions(event, scriptsPath)); - } else if (scaffold?.previewMode === 'svelte-component') { - steps.push(svelteComponentInstructions(event, scaffold, scriptsPath)); - } else if (scaffold && scaffold.sourceWritten === false) { - steps.push(deferredWrapperInstructions(event, scaffold, scriptsPath)); - } else if (scaffold) { - steps.push(`The wrapper is already written into ${scaffold.file}. Splice preview CSS plus all ${event.count} variants at line ${scaffold.insertLine} in ONE edit, following the returned cssAuthoring contract (styleTag, selector strategy, forbidden patterns). Each variant div holds exactly ONE top-level element (same tag as the original); first visible, others display: none.`); - } else { - steps.push(`Preflight could not scaffold${event.scaffoldError ? ` (${event.scaffoldError})` : ''}. Run node ${scriptsPath}/live-wrap.mjs --id ${id} --count ${event.count} --element-id "${event.element?.id || ''}" --classes "${(event.element?.classes || []).join(',')}" --tag "${event.element?.tagName || ''}" --text "". Keep the flags separate; --text disambiguates repeated siblings. On a fallback error, follow live.md's Handle fallback.`); - } - - steps.push(event.action && event.action !== 'impeccable' - ? `Action is "${event.action}": read reference/${event.action}.md before planning; its MUST params are non-negotiable. ${PLAN_POINTER}` - : `Freeform action: work from SKILL.md rules plus craft-floor.md; no sub-command file. ${PLAN_POINTER}`); - - steps.push(`When all ${event.count} variants are delivered: ${replyCmd(scriptsPath, id, 'done --file ')}. Then poll again. If generation fails after the browser flipped to GENERATING, reply --reply ${id} error "Short reason" so the bar resets (never live-accept --discard for this).`); - - return steps.map((s, i) => `${i + 1}. ${s}`).join('\n'); -} - -function svelteComponentInstructions(event, scaffold, scriptsPath) { - const dir = scaffold.componentDir; - const count = event.count; - return `Svelte component preview. EDIT the existing stubs ${dir}/v1.svelte ... v${count}.svelte in place; never delete or recreate them; do not read them back (the prop-substituted markup is in scaffold.componentStubMarkup). Keep the stub's control flow ({#each}, {#if}) and propContract prop names exactly; never flatten a loop into literal items. The stub \n`; -} - -function buildInsertVariantStub(variantNum) { - return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; -} - -/** - * Scaffold a component-preview session. The scaffold is AST-based: the app's - * own svelte compiler parses the selected markup, control-flow blocks are - * preserved (an each collection crosses the prop contract as ONE structured - * prop, its loop body verbatim), and constructs a detached preview cannot - * support return `{ fallback: 'source-preview', reason }` so the caller keeps - * the markup inside the route file instead of shipping a wrong preview. - */ -export function scaffoldSvelteComponentSession({ - id, - count, - sourceFile, - sourceStartLine, - sourceEndLine, - originalLines, - cwd = process.cwd(), -}) { - const originalMarkup = originalLines.join('\n'); - - const compiler = loadSvelteCompiler(cwd); - if (!compiler) { - return { fallback: 'source-preview', reason: 'svelte 5 compiler not resolvable from the app root' }; - } - const analysis = analyzeSvelteMarkup(originalMarkup, compiler.parse); - if (!analysis.ok) { - return { fallback: 'source-preview', reason: analysis.reason }; - } - - ensureRuntimeHelper(cwd); - const dir = componentSessionDir(id, cwd); - fs.mkdirSync(dir, { recursive: true }); - - const contract = analysis.contract; - const seeded = extractMatchingSourceCss( - safeReadSource(path.resolve(cwd, sourceFile)), - originalMarkup, - ); - const seededCss = seeded.css; - // The preview compiles in isolation, so NONE of these source rules applied - // to what the user approved. Accept enforces that preview truth: any of - // them the variant does not re-declare is superseded and removed, instead - // of re-attaching to the accepted markup through kept class names (the - // ".decisions grid grabs the new board" failure). Only the CLASS-matched - // selectors are candidates; tag rules style shared route elements. - const seededSelectors = [...seeded.supersedable]; - - const manifest = { - id, - previewMode: 'svelte-component', - contractVersion: 2, - sourceFile: sourceFile.split(path.sep).join('/'), - sourceStartLine, - sourceEndLine, - count, - propContract: contract, - originalMarkup, - seededSelectors, - componentDir: path.relative(cwd, dir).split(path.sep).join('/'), - // Absolute paths let the browser fall back to /@fs/ imports when the dev - // server's base or root makes root-relative URLs miss, and probe whether - // the preview tree is reachable at all before blaming a variant. - componentDirAbs: dir.split(path.sep).join('/'), - runtimeModule: `/${SVELTE_RUNTIME_FILE}`, - runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'), - probeModule: `/${SVELTE_PROBE_FILE}`, - probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'), - }; - - fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); - - for (let n = 1; n <= count; n++) { - const variantFile = path.join(dir, `v${n}.svelte`); - if (!fs.existsSync(variantFile)) { - fs.writeFileSync(variantFile, buildVariantStubV2(n, analysis.markupWithProps, contract, seededCss), 'utf-8'); - } - } - - return { - manifest, - manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), - componentDir: manifest.componentDir, - propContract: contract, - // Inlined so the generate event's scaffold payload carries the stub - // shape; the agent edits vN.svelte in place instead of spending reads on - // the manifest and stub files (or deleting and recreating them). - stubMarkup: analysis.markupWithProps, - seededCss, - }; -} - -function safeReadSource(filePath) { - try { return fs.readFileSync(filePath, 'utf-8'); } catch { return ''; } -} - -function escapeSelectorToken(token) { - return String(token).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Seed variant stubs with the source component's rules that already style the - * selected markup, so variants start from the real cascade (a detached - * preview inherits none of the route's compile-scoped CSS) instead of - * reimplementing it blind. - * - * Returns { css, supersedable }. `css` is every matching rule (class OR tag - * matched). `supersedable` holds only the CLASS-matched selectors: those are - * the accept-time removal candidates. Tag selectors (h1, a, p) style shared - * elements across the whole route, so they seed the preview but are never - * candidates for removal. - */ -export function extractMatchingSourceCss(routeSource, originalMarkup) { - const empty = { css: '', supersedable: new Set() }; - const styleMatch = String(routeSource || '').match(/]*>([\s\S]*?)<\/style\s*>/i); - if (!styleMatch) return empty; - const classNames = new Set(); - const classRe = /class\s*=\s*(["'])(.*?)\1/g; - let m; - while ((m = classRe.exec(originalMarkup))) { - for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); - } - const tagRe = /<([a-z][a-z0-9-]*)/gi; - const tags = new Set(); - while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); - if (classNames.size === 0 && tags.size === 0) return empty; - - // Token-boundary matching, never substring: `.btn` must not match - // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit - // seeds a rule that never styled the pick, and a falsely seeded selector - // becomes an accept-time DELETION of a hand-written rule. - const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); - const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); - const classMatches = (selector) => classRes.some((re) => re.test(selector)); - const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); - - const supersedable = new Set(); - const ruleMatches = (prelude) => { - let matched = false; - for (const selector of splitSelectorList(prelude)) { - if (classMatches(selector)) { - matched = true; - supersedable.add(normalizeSelector(selector)); - } else if (tagMatches(selector)) { - matched = true; - } - } - return matched; - }; - - const pick = (nodes) => { - const kept = []; - for (const node of nodes) { - if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); - else if (node.type === 'at' && node.children) { - const children = pick(node.children); - if (children.length) kept.push({ ...node, children }); - } - } - return kept; - }; - return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; -} - -function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { - const propsComment = contract.length > 0 - ? `\n\n` - : ''; - // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\n` - : `\n\n`; - return `${buildPropsScriptV2(contract)}${propsComment}${markupWithProps.trim()}\n${css}`; -} - -export function scaffoldSvelteComponentInsertSession({ - id, - count, - sourceFile, - insertLine, - position, - anchorStartLine, - anchorEndLine, - anchorLines, - cwd = process.cwd(), -}) { - ensureRuntimeHelper(cwd); - const dir = componentSessionDir(id, cwd); - fs.mkdirSync(dir, { recursive: true }); - - const anchorMarkup = (anchorLines || []).join('\n'); - const manifest = { - id, - mode: 'insert', - previewMode: 'svelte-component', - sourceFile: sourceFile.split(path.sep).join('/'), - insertLine, - position, - anchorStartLine, - anchorEndLine, - originalMarkup: anchorMarkup, - anchorMarkup, - count, - propContract: [], - componentDir: path.relative(cwd, dir).split(path.sep).join('/'), - componentDirAbs: dir.split(path.sep).join('/'), - runtimeModule: `/${SVELTE_RUNTIME_FILE}`, - runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'), - probeModule: `/${SVELTE_PROBE_FILE}`, - probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'), - }; - - fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); - - for (let n = 1; n <= count; n++) { - const variantFile = path.join(dir, `v${n}.svelte`); - if (!fs.existsSync(variantFile)) { - fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); - } - } - - return { - manifest, - manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), - componentDir: manifest.componentDir, - propContract: [], - }; -} - -export function findSvelteComponentManifest(id, cwd = process.cwd()) { - const direct = manifestPathForSession(id, cwd); - if (fs.existsSync(direct)) { - return readManifest(direct); - } - // Legacy location: a session scaffolded by an older version can still be - // accepted after an upgrade. - const legacyDirect = path.join(cwd, LEGACY_SVELTE_COMPONENT_ROOT, id, 'manifest.json'); - if (fs.existsSync(legacyDirect)) { - return readManifest(legacyDirect); - } - for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) { - const root = path.join(cwd, rootRel); - if (!fs.existsSync(root)) continue; - for (const entry of fs.readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const candidate = path.join(root, entry.name, 'manifest.json'); - if (!fs.existsSync(candidate)) continue; - try { - const manifest = readManifest(candidate); - if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; - } catch { /* skip */ } - } - } - return null; -} - -export function readManifest(manifestPath) { - const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); - return { - ...data, - manifestPath, - }; -} - -export function resolveSourceFile(sourceFile, cwd = process.cwd()) { - if (!sourceFile || path.isAbsolute(sourceFile)) { - throw new Error('Invalid svelte-component source file'); - } - const full = path.resolve(cwd, sourceFile); - const rel = path.relative(cwd, full); - if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { - throw new Error('Svelte-component source file escapes project root'); - } - if (!fs.existsSync(full)) { - throw new Error('Svelte-component source file not found: ' + sourceFile); - } - return full; -} - -function appendCssToSvelteStyle(lines, cssLines) { - const closeIdx = findLastStyleCloseLine(lines); - const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; - if (closeIdx === -1) { - return [...lines, '', '']; - } - return [ - ...lines.slice(0, closeIdx), - ...prepared, - ...lines.slice(closeIdx), - ]; -} - -function findLastStyleCloseLine(lines) { - for (let i = lines.length - 1; i >= 0; i--) { - if (/<\/style\s*>/.test(lines[i])) return i; - } - return -1; -} - -function bakeParamValuesInCss(cssLines, paramValues) { - if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; - return cssLines.map((line) => { - let out = line; - for (const [key, value] of Object.entries(paramValues)) { - const varName = `--p-${key}`; - out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); - } - return out; - }); -} - -function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { - const css = String((cssLines || []).join('\n')); - if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; - - const rules = parseCssRules(css); - const output = []; - for (const rule of rules) { - appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); - } - return output.join('\n') - .split('\n') - .map((line) => line.trimEnd()) - .filter((line) => line.trim() !== ''); -} - -function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { - const prelude = rule.prelude.trim(); - const body = rule.body.trim(); - if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; - - if (/^@scope\b/i.test(prelude)) { - if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; - const inner = parseCssRules(body); - for (const innerRule of inner) { - const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); - if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; - output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); - } - return; - } - - const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); - if (!rewrittenPrelude) return; - output.push(formatCssRule(rewrittenPrelude, body)); -} - -function parseCssRules(css) { - const rules = []; - const text = String(css || ''); - let i = 0; - while (i < text.length) { - while (i < text.length && /\s/.test(text[i])) i++; - const preludeStart = i; - while (i < text.length && text[i] !== '{') i++; - if (i >= text.length) break; - const prelude = text.slice(preludeStart, i).trim(); - i++; - const bodyStart = i; - let depth = 1; - let quote = null; - let comment = false; - while (i < text.length && depth > 0) { - const ch = text[i]; - const next = text[i + 1]; - if (comment) { - if (ch === '*' && next === '/') { - comment = false; - i += 2; - continue; - } - i++; - continue; - } - if (quote) { - if (ch === '\\') { - i += 2; - continue; - } - if (ch === quote) quote = null; - i++; - continue; - } - if (ch === '/' && next === '*') { - comment = true; - i += 2; - continue; - } - if (ch === '"' || ch === "'") { - quote = ch; - i++; - continue; - } - if (ch === '{') depth++; - else if (ch === '}') depth--; - i++; - } - const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); - if (prelude) rules.push({ prelude, body }); - } - return rules; -} - -function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { - const selectors = splitSelectorList(prelude); - const rewritten = []; - for (const selector of selectors) { - const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); - if (next) rewritten.push(next); - } - return rewritten.join(', '); -} - -function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { - let out = selector.trim(); - const hasVariant = /data-impeccable-variant/.test(out); - if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; - if (hasVariant) { - out = out.replace(variantSelectorRegex(variantNum), ''); - out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); - } - - const paramResult = rewriteParamSelectors(out, paramValues); - if (!paramResult.keep) return ''; - out = paramResult.selector; - - out = out - .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') - .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') - .replace(/\s+/g, ' ') - .trim(); - - out = out.replace(/^[>+~]\s*/, '').trim(); - if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; - return out; -} - -function rewriteParamSelectors(selector, paramValues) { - let keep = true; - const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { - if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; - const actual = paramValues[key]; - if (expected != null && String(actual) !== String(expected)) { - keep = false; - return ''; - } - if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { - keep = false; - return ''; - } - return ''; - }); - return { keep, selector: next }; -} - - -function selectorHasVariant(selector, variantNum) { - return variantSelectorRegex(variantNum).test(selector); -} - -function variantSelectorRegex(variantNum) { - return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); -} - -function formatCssRule(selector, body) { - return `${selector} { ${body.trim()} }`; -} - -function escapeRegExp(value) { - return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { - const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); - const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); - const resultBase = { - file: manifest.sourceFile, - sourceFile: manifest.sourceFile, - previewMode: 'svelte-component', - componentDir: manifest.componentDir, - carbonize: false, - }; - if (!fs.existsSync(variantPath)) { - return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; - } - - const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); - if (manifest.mode === 'insert') { - return inlineSvelteComponentInsertAccept({ - manifest, - markup, - cssLines, - variantNum, - paramValues, - sourceFile, - resultBase, - cwd, - }); - } - - const rootTag = matchOpeningTag(markup)?.tag || 'div'; - const contract = manifest.propContract || []; - const compiler = loadSvelteCompiler(cwd); - const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); - - // Restore props back to route expressions. Contract v2 restores through the - // AST so a prop used without braces (each headers, attribute positions) - // still maps back to its original expression; v1 falls back to the textual - // placeholder swap. - let restoredText; - if (Number(manifest.contractVersion) === 2 && compiler) { - const restored = restoreSvelteMarkup(mergedMarkup, contract, compiler.parse); - if (!restored.ok) { - return { handled: false, error: 'Accepted variant does not parse: ' + restored.reason, ...resultBase }; - } - restoredText = restored.markup; - } else { - restoredText = substitutePropsWithExprs(mergedMarkup, contract); - } - const restoredMarkup = restoredText.split('\n').map((line) => line.trimEnd()); - - const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); - const sourceLines = sourceContent.split('\n'); - const start = Number(manifest.sourceStartLine) - 1; - const end = Number(manifest.sourceEndLine) - 1; - if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { - return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; - } - - const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; - const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent); - - let newLines = [ - ...sourceLines.slice(0, start), - ...indentedMarkup, - ...sourceLines.slice(end + 1), - ]; - - // Selectors that were already unused before this accept are the user's - // pre-existing code; the pruning pass must not touch them. - const preUnused = compiler ? collectUnusedSelectors(sourceContent, compiler.compile) : new Set(); - - // Bake params (declared kinds from params.json drive branch pruning), then - // MERGE into the component's existing style block: matching selectors are - // replaced, new ones appended. Appending alone is how superseded rules used - // to survive their own replacement. - const declaredParams = readDeclaredParams(manifest, variantNum, cwd); - let variantCss = cssLines.join('\n'); - if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) { - // Defensive: strip preview-wrapper selectors that authoring rules forbid - // on this path but an off-spec agent may still emit. - variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n'); - } - const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {}); - const cssStats = { replaced: 0, appended: 0, pruned: [], superseded: [] }; - if (bakedCss.trim()) { - const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss); - newLines = merged.text.split('\n'); - cssStats.replaced = merged.replaced; - cssStats.appended = merged.appended; - } - - let finalText = newLines.join('\n'); - - // Preview truth: the detached preview never applied the source rules that - // styled the replaced selection, so the user approved a design without - // them. Any seeded selector the variant did not re-declare is superseded; - // left in place it re-attaches through kept class names (the accepted root - // keeps its original classes) and re-layouts markup it no longer owns. - // - // Removal is bounded by ownership: a selector whose classes are still used - // by route markup OUTSIDE the replaced region does not belong to the pick - // alone, and removing it would strip styling from markup this accept never - // touched. Keeping it risks a visible re-attachment quirk on the accepted - // region; deleting it breaks the rest of the route. Keep it. - const outsideMarkup = [...sourceLines.slice(0, start), ...sourceLines.slice(end + 1)] - .join('\n') - .replace(/]*>[\s\S]*?<\/style\s*>/gi, ''); - const outsideClasses = new Set(); - { - const attrRe = /class\s*=\s*(["'])(.*?)\1/g; - let cm; - while ((cm = attrRe.exec(outsideMarkup))) { - for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); - } - const directiveRe = /class:([A-Za-z0-9_-]+)/g; - while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); - } - const usedOutsideReplacedRegion = (selector) => { - const classTokenRe = /\.([A-Za-z0-9_-]+)/g; - let tm; - while ((tm = classTokenRe.exec(selector))) { - if (outsideClasses.has(tm[1])) return true; - } - return false; - }; - const incomingSelectors = collectAllSelectors(bakedCss); - const superseded = (manifest.seededSelectors || []) - .map((selector) => normalizeSelector(selector)) - .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); - if (superseded.length > 0) { - const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); - finalText = scrubbed.text; - cssStats.superseded = scrubbed.removed; - } - - if (compiler) { - const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); - finalText = pruned.source; - cssStats.pruned = pruned.removed; - } - - // Postcondition: no selector from the user's pre-accept CSS may vanish - // unless the compiler-driven prune or the preview-truth supersession - // deliberately removed it. This turns any parser or reconciler defect into - // a loud refusal instead of silent damage to a hand-written style block. - const lostSelectors = findLostSelectors(sourceContent, finalText, [ - ...cssStats.pruned, - ...cssStats.superseded, - ]); - if (lostSelectors.length > 0) { - return { - handled: false, - error: 'CSS reconciliation would lose selectors from the existing style block: ' - + lostSelectors.join(', ') - + '. Source not modified; accept the variant manually.', - mode: 'error', - ...resultBase, - }; - } - - try { - fs.writeFileSync(sourceFile, finalText, 'utf-8'); - } catch (err) { - return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; - } - removeSvelteComponentSession(manifest.id, cwd); - - const verify = verifyAcceptedSource(finalText); - return { - handled: true, - css: cssStats, - verify, - ...resultBase, - }; -} - -/** Re-indent a block onto `indent` while preserving its internal structure. */ -export function reindentPreservingStructure(lines, indent) { - const nonEmpty = lines.filter((line) => line.trim() !== ''); - if (nonEmpty.length === 0) return lines.map(() => ''); - const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); - return lines.map((line) => { - if (line.trim() === '') return ''; - const current = (line.match(/^\s*/) || [''])[0].length; - return indent + line.slice(Math.min(minIndent, current)); - }); -} - -function styleBlockText(sourceText) { - const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); - return match ? match[1] : ''; -} - -/** - * Remove every rule whose (normalized) selector list is fully contained in - * `selectors` from the component's style block, at any at-rule nesting depth. - * Rules that mix doomed and surviving selectors keep the survivors. - */ -export function removeSelectorsFromSvelteSource(sourceText, selectors) { - const text = String(sourceText || ''); - const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; - let lastMatch = null; - let m; - while ((m = styleRe.exec(text))) lastMatch = m; - if (!lastMatch) return { text, removed: [] }; - - const removed = []; - const transform = (nodes) => { - const kept = []; - for (const node of nodes) { - if (node.type === 'rule') { - const survivors = []; - for (const selector of splitSelectorList(node.prelude)) { - if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); - else survivors.push(selector); - } - if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); - } else if (node.type === 'at' && node.children) { - const children = transform(node.children); - if (children.length > 0) kept.push({ ...node, children }); - } else { - kept.push(node); - } - } - return kept; - }; - - const nodes = transform(parseStylesheet(lastMatch[1])); - if (removed.length === 0) return { text, removed }; - const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); - const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; - return { - text: text.slice(0, lastMatch.index) + rebuilt + text.slice(lastMatch.index + lastMatch[0].length), - removed, - }; -} - -export function findLostSelectors(beforeSource, afterSource, prunedSelectors = []) { - const before = collectAllSelectors(styleBlockText(beforeSource)); - const after = collectAllSelectors(styleBlockText(afterSource)); - const pruned = new Set((prunedSelectors || []).map((s) => normalizeSelector(s))); - const lost = []; - for (const selector of before) { - if (!after.has(selector) && !pruned.has(selector)) lost.push(selector); - } - return lost; -} - -function readDeclaredParams(manifest, variantNum, cwd) { - try { - const raw = JSON.parse(fs.readFileSync(path.join(cwd, manifest.componentDir, 'params.json'), 'utf-8')); - const list = raw?.[String(variantNum)]; - return Array.isArray(list) ? list : []; - } catch { - return []; - } -} - -/** - * Merge CSS into a svelte component's top-level style block (created when - * absent), replacing rules whose selectors match and appending the rest. - */ -export function mergeCssIntoSvelteSource(sourceText, incomingCss) { - const text = String(sourceText || ''); - const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; - let lastMatch = null; - let m; - while ((m = styleRe.exec(text))) lastMatch = m; - - if (!lastMatch) { - const { css, replaced, appended } = reconcileCss('', incomingCss); - return { - text: `${text.replace(/\s*$/, '')}\n\n\n`, - replaced, - appended, - }; - } - - const inner = lastMatch[1]; - const { css, replaced, appended } = reconcileCss(inner, incomingCss); - const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); - const replacedBlock = `${openTag}\n${indentCssBlock(css)}\n`; - return { - text: text.slice(0, lastMatch.index) + replacedBlock + text.slice(lastMatch.index + lastMatch[0].length), - replaced, - appended, - }; -} - -function indentCssBlock(css) { - return String(css || '') - .split('\n') - .map((line) => (line.trim() === '' ? '' : ' ' + line)) - .join('\n'); -} - -function inlineSvelteComponentInsertAccept({ - manifest, - markup, - cssLines, - variantNum, - paramValues, - sourceFile, - resultBase, - cwd, -}) { - if (!svelteMarkupHasVisibleContent(markup)) { - return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; - } - if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { - return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; - } - - const rootTag = matchOpeningTag(markup)?.tag || 'div'; - const restoredMarkup = String(markup || '') - .split('\n') - .map((line) => line.trimEnd()); - const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); - const sourceLines = sourceContent.split('\n'); - const insertIndex = Number(manifest.insertLine) - 1; - if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { - return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; - } - - const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; - const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; - const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent); - - let newLines = [ - ...sourceLines.slice(0, insertIndex), - ...indentedMarkup, - ...sourceLines.slice(insertIndex), - ]; - - let variantCss = cssLines.join('\n'); - if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) { - variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n'); - } - const declaredParams = readDeclaredParams(manifest, variantNum, cwd); - const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {}); - if (bakedCss.trim()) { - const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss); - newLines = merged.text.split('\n'); - } - - try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); - } catch (err) { - return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; - } - removeSvelteComponentSession(manifest.id, cwd); - - const verify = verifyAcceptedSource(newLines.join('\n')); - return { - handled: true, - verify, - ...resultBase, - }; -} - -function svelteMarkupHasVisibleContent(markup) { - const text = String(markup || '') - .replace(//gi, '') - .replace(//gi, '') - .replace(//g, '') - .replace(/<[^>]+>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (text.length > 0) return true; - return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); -} - -function mergeOriginalTopLevelAttrs(markup, originalMarkup) { - const variantOpen = matchOpeningTag(markup); - const originalOpen = matchOpeningTag(originalMarkup); - if (!variantOpen || !originalOpen) return markup; - if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; - - const variantAttrs = parseAttrSegments(variantOpen.attrs); - const originalAttrs = parseAttrSegments(originalOpen.attrs); - const additions = []; - let attrs = variantOpen.attrs; - - const originalClass = originalAttrs.get('class'); - const variantClass = variantAttrs.get('class'); - if (originalClass && variantClass) { - const merged = mergeStaticClassAttr(originalClass, variantClass); - if (merged) { - attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); - variantAttrs.set('class', { ...variantClass, raw: merged }); - } - } else if (originalClass && !variantClass) { - additions.push(originalClass.raw); - } - - for (const [name, attr] of originalAttrs) { - if (name === 'class') continue; - if (!variantAttrs.has(name)) additions.push(attr.raw); - } - - if (additions.length === 0 && attrs === variantOpen.attrs) return markup; - const nextOpen = variantOpen.prefix - + variantOpen.tag - + attrs - + additions.map((attr) => ' ' + attr.trim()).join('') - + variantOpen.close; - return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); -} - -function matchOpeningTag(markup) { - const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); - if (!match) return null; - return { - raw: match[0], - prefix: match[1], - tag: match[2], - attrs: match[3] || '', - close: match[4], - index: match.index || 0, - }; -} - -function parseAttrSegments(attrs) { - const out = new Map(); - const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; - let match; - while ((match = re.exec(attrs))) { - const raw = match[0]; - const name = match[1]; - out.set(name, { - name, - raw, - start: match.index, - end: match.index + raw.length, - }); - } - return out; -} - -function mergeStaticClassAttr(originalClass, variantClass) { - const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); - const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); - if (!originalValue || !variantValue) return null; - const quote = variantValue[1]; - const classes = [ - ...variantValue[2].split(/\s+/), - ...originalValue[2].split(/\s+/), - ].filter(Boolean); - return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; -} - -export function removeSvelteComponentSession(id, cwd = process.cwd()) { - const dir = componentSessionDir(id, cwd); - try { - fs.rmSync(dir, { recursive: true, force: true }); - } catch { /* non-fatal */ } -} - -/** - * Compile-check every variant component of a session with the app's own - * compiler, BEFORE the browser ever imports them. A variant that does not - * compile (the classic: a second top-level - - - -
-
- - Impeccable -
-
-
-
-
- -

${esc(payload.title || 'Choose a direction')}

-
- ${payload.question ? `

${esc(payload.question)}

` : ''} -
-
${cards}
- - - - -
-
-
-
- ${payload.steer ? '' : ''} - ${payload.reroll ? '' : ''} - ${payload.canon && !payload.canonCard ? '' : ''} -
-`; -} - -const server = http.createServer((req, res) => { - if (req.method === 'GET' && req.url === '/') { - const pending = nextFile(); - if (pending && fs.existsSync(pending)) { - try { loadRound(fs.readFileSync(pending, 'utf8')); fs.rmSync(pending); } catch { /* keep current round */ } - } - res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); - res.end(page()); - return; - } - if (req.method === 'POST' && req.url === '/heartbeat') { - res.writeHead(204); res.end(); - if (detachedKey) { - const now = Date.now(); - if (!server.lastBeatWrite || now - server.lastBeatWrite > 4000) { - server.lastBeatWrite = now; - try { - const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8')); - state.lastBeat = now; - fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state)); - } catch { /* state file recreated on next beat */ } - } - } - return; - } - if (req.method === 'GET' && req.url === '/next-status') { - const pending = nextFile(); - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) })); - return; - } - const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/); - if (imageMatch) { - const abs = localImages[Number(imageMatch[1])]; - if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; } - const type = abs.endsWith('.webp') ? 'image/webp' - : abs.endsWith('.png') ? 'image/png' - : abs.endsWith('.svg') ? 'image/svg+xml' - : abs.endsWith('.gif') ? 'image/gif' - : 'image/jpeg'; - res.writeHead(200, { 'content-type': type }); - fs.createReadStream(abs).pipe(res); - return; - } - if (req.method === 'POST' && req.url === '/answer') { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); - let parsed = {}; - try { parsed = JSON.parse(body); } catch { /* empty steer */ } - const chosen = options.find((o) => o.id === parsed.optionId); - const answer = JSON.stringify({ - optionId: parsed.optionId ?? null, - steer: parsed.steer ?? '', - ...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}), - ...(chosen?.sketch ? { sketch: chosen.sketch } : {}), - }); - const isReroll = parsed.optionId === 'reroll'; - if (detachedKey) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(answerFile(detachedKey), answer + '\n'); - } else { - printAnswer(answer); - } - // A re-roll in detached mode keeps the table open: the client shows a - // loading hand and reloads when --update delivers the next round. - if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150); - }); - return; - } - res.writeHead(404); res.end(); -}); - -server.listen(portArg, '127.0.0.1', () => { - const { port } = server.address(); - const url = `http://127.0.0.1:${port}/`; - if (hasFlag('detached-serve')) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(stateFile(arg('key')), JSON.stringify({ pid: process.pid, port, url })); - } else { - console.log(`QUESTION URL: ${url}`); - console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...'); - } - if (!hasFlag('no-open')) { - const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; - try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ } - } - if (timeoutSec > 0) { - setTimeout(() => { - console.log('serve-question: timed out with no answer'); - process.exit(2); - }, timeoutSec * 1000).unref?.(); - } -}); diff --git a/.github/skills/impeccable/scripts/surface-brief.mjs b/.github/skills/impeccable/scripts/surface-brief.mjs deleted file mode 100644 index 723f7c1..0000000 --- a/.github/skills/impeccable/scripts/surface-brief.mjs +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { resolveProjectRoot } from './context.mjs'; -import { - listSurfaceBriefs, - resolveSurfaceBrief, - surfaceBriefPathForTarget, - writeSurfaceBrief, -} from './lib/surface-briefs.mjs'; - -function summary(brief, projectRoot) { - return { - slug: brief.slug, - path: path.relative(projectRoot, brief.path).split(path.sep).join('/'), - primaryTarget: brief.primaryTarget, - relatedTargets: brief.relatedTargets, - }; -} - -function main(argv) { - const [command, target, bodyFile, ...relatedTargets] = argv; - const projectRoot = resolveProjectRoot(process.cwd(), target ? { targetPath: target } : {}); - if (command === 'path') { - const filePath = surfaceBriefPathForTarget(target, { projectRoot }); - if (!filePath) throw new Error('surface brief path requires a concrete target'); - process.stdout.write(`${path.relative(process.cwd(), filePath) || filePath}\n`); - return; - } - if (command === 'list') { - process.stdout.write(`${JSON.stringify(listSurfaceBriefs(projectRoot).map((brief) => summary(brief, projectRoot)), null, 2)}\n`); - return; - } - if (command === 'read') { - const result = resolveSurfaceBrief(projectRoot, target || null); - if (result.brief) { - process.stdout.write(result.brief.text); - return; - } - if (result.candidates.length) process.stderr.write(`${JSON.stringify(result.candidates.map((brief) => summary(brief, projectRoot)), null, 2)}\n`); - process.exit(2); - } - if (command === 'write') { - if (!target || !bodyFile) throw new Error('usage: surface-brief.mjs write '); - const filePath = writeSurfaceBrief({ - projectRoot, - primaryTarget: target, - relatedTargets, - body: fs.readFileSync(bodyFile, 'utf-8'), - }); - process.stdout.write(`${path.relative(process.cwd(), filePath) || filePath}\n`); - return; - } - throw new Error('usage: surface-brief.mjs [target] [body-file] [related-target ...]'); -} - -function isMainModule() { - if (!process.argv[1]) return false; - try { - return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]); - } catch { - return import.meta.url === pathToFileURL(process.argv[1]).href; - } -} - -if (isMainModule()) { - try { - main(process.argv.slice(2)); - } catch (error) { - process.stderr.write(`${error?.message || error}\n`); - process.exit(1); - } -} diff --git a/public/images/copilot-cli.png b/public/images/copilot-cli.png new file mode 100644 index 0000000..c19d3dc Binary files /dev/null and b/public/images/copilot-cli.png differ diff --git a/src/components/doom-badge-links.astro b/src/components/doom-badge-links.astro index bc355fe..cea836f 100644 --- a/src/components/doom-badge-links.astro +++ b/src/components/doom-badge-links.astro @@ -4,7 +4,7 @@ import { RotateCcw } from 'lucide-react' diff --git a/src/components/doom-simulator.astro b/src/components/doom-simulator.astro index 0ba032f..4b86945 100644 --- a/src/components/doom-simulator.astro +++ b/src/components/doom-simulator.astro @@ -31,8 +31,8 @@ import { sitePath } from '@/lib/site-path'
GitHub badge with DOOM running on its screen -
- +

Loading 3D badge view

diff --git a/src/components/doom-video.astro b/src/components/doom-video.astro index eccafd2..8782307 100644 --- a/src/components/doom-video.astro +++ b/src/components/doom-video.astro @@ -1,3 +1,7 @@ +--- +import DoomCopilotTerminal from './doom-copilot-terminal.astro' +--- +