Merge origin/main into rust-swap

Textual merge only. The JS engine and skill scripts stay deleted on this
branch; every behavior change they carried is ported to the Rust crates in
the commits that follow.

Conflict resolutions:
- skill/SKILL.src.md, skill/reference/new-work.md: main's new wording, with
  the branch's launcher invocations kept in place of `node <script>.mjs`.
- scripts/test-suites.mjs: registers main's new build-tooling tests
  (copy-provider-commands, root-commands-sync, opencode-commands) and leaves
  the tests for deleted JS modules deregistered.
- Every modify/delete conflict under cli/engine, cli/bin/commands,
  skill/scripts/*.mjs and tests/ for deleted modules keeps the deletion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
This commit is contained in:
Paul Bakaus
2026-09-03 12:01:19 -07:00
co-authored by Claude Fable 5.1
623 changed files with 121068 additions and 7018 deletions
@@ -16,74 +16,23 @@ When the parent hands you a decision card packet instead of an approved mock, th
## Input Contract
Expect:
Expect the measured spec (`.impeccable/build/spec.json`, written by `comp-spec.mjs` 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.
- 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.
If there is no spec, stop and return one line asking the parent to run `comp-spec.mjs` first. You do not inventory the comp yourself; the spec is the inventory, and a second inventory disagrees with the first.
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.
## The job
Defaults unless contradicted:
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.
- `.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. Never default to the small size of a full-page mock crop.
- Remove UI text, navigation, buttons, labels, and body copy.
- 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.
- Keep the final assets directory clean: only files the build will consume. Source crops, reference crops, masks, and contact sheets go in a sibling `_sources`, `sources`, or review folder.
Per region, in the spec's order:
Ask blockers once, globally. Missing source path/crops or output directory blocks production. Exact dimensions, compression targets, retina variants, and format preferences do not; choose defaults and report them.
1. `node .github/skills/impeccable/scripts/comp-spec.mjs --crop <id>` writes the reference crop under `.impeccable/build/crops/`.
2. Produce the plate. With the API fallback: `node .github/skills/impeccable/scripts/generate-image.mjs --plate <id> --quality high` does the whole step (crop as reference, the spec's plate prompt, output size chosen from the region's aspect, the file written to its plate path, prompt embedded, and the plate scored against the crop). With a harness-native image tool: use the crop as the input image and `node .github/skills/impeccable/scripts/comp-spec.mjs --plate-prompt <id>` as the prompt, write the result to the plate path, then run `node .github/skills/impeccable/scripts/embed-prompt.mjs <plate> --prompt "<the exact prompt>"`.
3. Read the score line. `PLATE-SCORE` under 50%, or a `PLATE-WARN`, means the plate does not read as the region: open the plate beside the crop, name what drifted (subject, framing, palette, style), tighten the prompt with that, and regenerate once. Two misses on one region: keep the better plate, mark it `needs_parent_review`, and say why in one line.
4. Transparent cutouts (a figure or object on the page ground): generate on a flat chroma color absent from the subject and key it to alpha before writing the PNG; never ship the keyed background.
## 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: 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, and a shipped crop, however close it looks, is how a beautiful comp becomes 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 a semantic HTML/CSS/SVG recommendation when 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 <asset> --prompt "<the prompt used>"` so the prompt lives inside the image itself. 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.
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 first, classify it as crop-derived cleanup or clean-plate work.
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. Ship a screenshot raster only when the parent explicitly says the screenshot itself is the final asset.
Semantic does not mean ignored. For every semantic role, write a concrete implementation handoff for the parent craft agent: the DOM/component layers, CSS-owned visual treatment, SVG/canvas/icon-library pieces, responsive behavior, and which nearby produced raster assets it composes with. For logos and icons, prefer inline SVG/vector or icon-library implementation unless the parent provides a production logo raster.
## Prompt Pattern
Use this shape for image-to-image work:
```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.
```
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` is a concrete build handoff, not a note that no asset was produced: name the likely HTML/CSS/SVG/canvas/icon/component pieces and the visual responsibilities code owns.
`qa_status` is `accepted`, `needs_parent_review`, or `blocked`. `accepted` only after visual comparison passes. `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. `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; per-asset rows 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: `<id> <plate path> <WxH> <score>% <accepted|needs_parent_review|blocked> <one-line note or ->`. 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 `build-phase.mjs advance` to verify the plates against the same spec; your line and its line must agree.
@@ -12,16 +12,16 @@ A hard turn ceiling ends the run without warning; a run that ends before its con
## Input Contract
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); 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.
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/<id>.png` paired crops, and `report.json` with per-region scores and verdicts from `comp-diff.mjs`); 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
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-<width>.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/review/hero-repro.png` exists: the hero reproduction checkpoint's capture at the comp's own dimensions; its absence means the reproduction phase ran unproven, a material finding. 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.** 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: <region> 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.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
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: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport.
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.
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 the last two live sessions shipped five kickers past a reviewer that never looked.
Do not run a second detector pass; mechanical findings belong to the parent's hooks.
+2 -2
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 4.1.2
version: 4.1.3
user-invocable: true
argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
license: Apache 2.0
@@ -17,7 +17,7 @@ Core principles:
## Setup
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .github/skills/impeccable/scripts/...` command in this skill and its references, and `.github/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. 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.
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 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.
## How to design
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .github/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .github/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .github/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
@@ -91,7 +91,7 @@
import crypto from 'node:crypto';
import { dirname, join, relative, resolve } from 'node:path';
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
approvedPoolRevision,
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
export function sameMainModulePath(left, right, platform = process.platform) {
if (platform !== 'win32') return left === right;
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
}
function isMainModule() {
if (!process.argv[1]) return false;
try {
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
// invoked path. Compare real paths so a linked skill still runs its CLI,
// normalizing the drive-letter casing that Windows junctions can change.
return sameMainModulePath(
realpathSync(process.argv[1]),
realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
}
if (isMainModule()) {
const args = process.argv.slice(2);
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
+138 -12
View File
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
if (!repoRoot && targetGitRoot) {
const cwdGitRoot = findGitBoundaryRoot(absCwd);
if (targetGitRoot !== cwdGitRoot) {
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
repoRoot: targetGitRoot,
isMonorepo: false,
};
}
}
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
const targetIsExternal = hasTargetOption(options)
&& targetDir !== absCwd
&& !isPathInside(targetDir, absCwd);
if (targetIsExternal) {
const targetRepoRoot = targetGitRoot || targetDir;
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
repoRoot: targetRepoRoot,
isMonorepo: false,
};
}
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
function findGitBoundaryRoot(startDir) {
let dir = path.resolve(startDir);
const homeDir = path.resolve(os.homedir());
while (true) {
if (dir === homeDir) return null;
if (hasGitBoundary(dir)) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -248,10 +285,31 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}
function resolveTargetPath(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
if (fs.existsSync(abs)) return abs;
return findUniqueBareTarget(cwd, targetPath) || abs;
}
function findUniqueBareTarget(cwd, targetPath) {
const absCwd = path.resolve(cwd);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
const rel = path.relative(absCwd, abs);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
const segments = rel.split(path.sep).filter(Boolean);
if (segments.length !== 1) return null;
const name = segments[0];
const repoRoot = findMonorepoRoot(absCwd);
if (!repoRoot) return null;
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
if (matches.length !== 1) return null;
return path.resolve(repoRoot, matches[0].path);
}
function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
const abs = resolveTargetPath(cwd, targetPath);
try {
const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs);
@@ -962,13 +1020,45 @@ export function extractPlatform(product) {
* (this file lives at `<skill>/scripts/context.mjs`). Returns null when the
* frontmatter is missing or unreadable.
*/
function parseSkillFrontmatterVersion(content) {
const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/);
if (!match) return null;
let metadataVersion = null;
let topLevelVersion = null;
let inMetadata = false;
let metadataIndent = null;
for (const line of match[1].split(/\r?\n/)) {
if (!line.trim() || line.trimStart().startsWith('#')) continue;
const indentText = line.match(/^[ \t]*/)[0];
const indent = indentText.replace(/\t/g, ' ').length;
if (indent === 0) {
inMetadata = /^metadata:\s*(?:#.*)?$/.test(line);
metadataIndent = null;
const version = line.match(/^version:\s*(.+?)\s*$/);
if (version) topLevelVersion = version[1];
continue;
}
if (!inMetadata) continue;
if (metadataIndent === null) metadataIndent = indent;
if (indent !== metadataIndent) continue;
const version = line.trim().match(/^version:\s*(.+?)\s*$/);
if (version) metadataVersion = version[1];
}
const value = metadataVersion || topLevelVersion;
return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null;
}
function readLocalSkillVersion() {
try {
const here = path.dirname(fileURLToPath(import.meta.url));
const skillMd = path.join(here, '..', 'SKILL.md');
const content = fs.readFileSync(skillMd, 'utf-8');
const match = content.match(/^version:\s*(.+)$/m);
return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
return parseSkillFrontmatterVersion(content);
} catch {
return null;
}
@@ -1119,13 +1209,19 @@ async function cli() {
throw err;
}
const targetProvided = hasTargetOption(cliOptions);
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
const resolvedTargetPath = targetProvided
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0);
}
const ctx = loadContext(process.cwd(), cliOptions);
const ctx = loadContext(
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) {
@@ -1239,11 +1335,6 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
}
function pathExistsForTarget(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
return fs.existsSync(abs);
}
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'],
@@ -1281,6 +1372,39 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']);
// Harness project settings are discovered by walking up from the resolved
// project root. Its hook manifest can live at an enclosing git root, so
// checking only projectRoot produces a false MANUAL_DETECTOR_REQUIRED
// directive. Starting from projectRoot also prevents an explicit target from
// borrowing an unrelated manifest near the caller. The walk itself is the
// authority: do not append repoRoot afterward, because resolveProject can
// retain an outer workspace root for a target inside an independent nested
// Git repository.
function hookManifestSearchRoots(ctx) {
const roots = [];
const seen = new Set();
const add = (root) => {
if (!root) return;
const resolved = path.resolve(root);
if (seen.has(resolved)) return;
seen.add(resolved);
roots.push(resolved);
};
let current = path.resolve(ctx.projectRoot || process.cwd());
const home = path.resolve(os.homedir());
while (true) {
if (current === home) break;
add(current);
if (hasGitBoundary(current)) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return roots;
}
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1288,8 +1412,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
for (const root of roots) {
for (const root of hookManifestSearchRoots(ctx)) {
// A manifest can live above the resolved product. Honor the hook lifecycle
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js}.
* Detected but not auto-patched in v1.
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
* but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
const NEXT_MIDDLEWARE_FILES = new Set([
'middleware.ts',
'middleware.js',
'middleware.mjs',
]);
const NEXT_PROXY_FILES = new Set([
'proxy.ts',
'proxy.js',
'proxy.mjs',
]);
const NEXT_CONFIG_FILES = [
'next.config.js',
'next.config.mjs',
'next.config.cjs',
'next.config.ts',
'next.config.mts',
'next.config.cts',
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
function hasNextProjectMarker(projectRoot) {
if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
try {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
return ['dependencies', 'devDependencies', 'peerDependencies']
.some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
} catch {
return false;
}
}
function isNextRequestHookFile(root, absPath, relPath, base) {
if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
if (!NEXT_PROXY_FILES.has(base)) return false;
const normalized = relPath.split(path.sep).join('/').toLowerCase();
// Next.js 16 recognizes proxy at the project root or in the optional src/
// directory, alongside app/ or pages/. The scan root is commonly a
// monorepo, so also accept that placement relative to a nested directory
// that carries a concrete Next.js project marker. A same-named helper
// elsewhere in the tree is not the framework request hook.
if (normalized === base || normalized === `src/${base}`) return true;
const hookDir = path.dirname(absPath);
const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
? path.dirname(hookDir)
: hookDir;
if (path.resolve(projectRoot) === path.resolve(root)) return true;
return hasNextProjectMarker(projectRoot);
}
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
@@ -1228,14 +1228,17 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity: f.severity || ap?.severity || 'warning',
severity,
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
advisory: (ap && ap.advisory === true) || f.advisory === true,
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -1277,6 +1280,381 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -1650,18 +2028,16 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
if (matches.length === 0) return false;
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
return matches.some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -37,13 +37,30 @@ function fileUrlToLocalPath(url) {
}
}
const URL_TARGET_RE = /^(?:https?|file):\/\//i;
// Some agent runners hand a shell-ready URL list to Node as one argv value.
// A browser accepts the spaces as part of one encoded URL, producing a
// plausible scan attributed to a bogus joined path. Expand only when every
// whitespace-delimited token is independently a URL, preserving ordinary
// filesystem paths that contain spaces.
function expandJoinedUrlTargets(targets) {
return targets.flatMap((target) => {
if (!/\s/.test(target)) return [target];
const parts = target.trim().split(/\s+/).filter(Boolean);
return parts.length > 1 && parts.every(part => URL_TARGET_RE.test(part))
? parts
: [target];
});
}
// Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
// filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs).
function isAdvisory(finding) {
return finding && finding.advisory === true;
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
}
function partitionAdvisory(findings) {
@@ -168,6 +185,16 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the
failure count so they never block automation. --no-advisory hides them.
Output streams:
Human-readable findings go to stderr so stdout stays available for structured
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
Exit status:
0 Scan completed with no primary findings (advisories may still be listed)
1 At least one requested target could not be scanned
2 Scan completed with primary findings
Operational failure takes precedence when a multi-target scan is partial.
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -185,7 +212,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs)
http(s):// and file:// URLs; accessible linked CSS included)
Examples:
impeccable detect src/
@@ -283,11 +310,16 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
};
const targets = args.filter(a => !a.startsWith('--'));
const targets = expandJoinedUrlTargets(args.filter(a => !a.startsWith('--')));
if (helpMode) { printUsage(); process.exit(0); }
let allFindings = [];
let hadOperationalFailure = false;
const reportLocalScanFailure = (target, error) => {
hadOperationalFailure = true;
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
};
if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor);
@@ -297,13 +329,23 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine).
const urlRe = /^(?:https?|file):\/\//i;
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
let browserDetector = null;
let browserSetupFailed = false;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
try {
for (const target of paths) {
if (urlRe.test(target)) {
if (URL_TARGET_RE.test(target)) {
if (browserSetupFailed) continue;
// A file:// URL points at a local artifact, so its design system
// resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never
@@ -316,14 +358,21 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target));
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
} catch (e) {
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue;
}
const resolved = path.resolve(target);
let stat;
try { stat = fs.statSync(resolved); }
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
catch {
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -352,7 +401,7 @@ async function detectCli() {
}
}
const files = walkDir(resolved)
const files = walkDir(resolved, reportLocalScanFailure)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -368,7 +417,11 @@ async function detectCli() {
}
// Build import graph for multi-file awareness
const graph = buildImportGraph(files);
const unreadableFiles = new Set();
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it
const importedByMap = new Map();
for (const [importer, imports] of graph) {
@@ -379,24 +432,33 @@ async function detectCli() {
}
for (const file of files) {
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
if (unreadableFiles.has(file)) continue;
try {
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
}
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
}
allFindings.push(...fileFindings);
}
} else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
try {
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
}
}
} finally {
@@ -413,6 +475,10 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -423,10 +489,10 @@ async function detectCli() {
}
}
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(primary.length > 0 ? 2 : 0);
process.exit(exitCode);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(0);
process.exit(exitCode);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
// var() fallbacks leave the closing parenthesis on the final token. Strip
// it before length resolution so `8px)` is not treated as unitless 8rem.
.map(token => token.trim().replace(/\)+$/, ''))
.filter(Boolean);
}
@@ -159,7 +159,7 @@ const ANTIPATTERNS = [
scopes: ['type'],
name: 'Flat type hierarchy',
description:
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.',
skillSection: 'Typography',
skillGuideline: 'flat type hierarchy',
},
@@ -358,7 +358,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
advisory: true,
severity: 'advisory',
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -1961,7 +1961,10 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5184,6 +5187,88 @@ function checkElementGlow(tag, style, effectiveBg) {
// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption';
const TYPE_HIERARCHY_MIN_ROLES = 3;
const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25;
function typeHierarchyRole(el) {
const tag = String(el?.tagName || el?.nodeName || '').toLowerCase();
return /^h[1-6]$/.test(tag) ? tag : 'body';
}
function hasTextContent(el) {
return String(el?.textContent || '').trim().length > 0;
}
function isRenderedTypeElement(el, getStyle) {
for (let current = el; current; current = current.parentElement) {
const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null;
if (current.hidden || hiddenAttr) return false;
const style = getStyle(current);
if (!style) continue;
const display = String(style.display || '').toLowerCase();
const visibility = String(style.visibility || '').toLowerCase();
const contentVisibility = String(style.contentVisibility || '').toLowerCase();
if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false;
const opacity = parseFloat(style.opacity);
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
}
return true;
}
function dominantTypeRoleSize(samples) {
const counts = new Map();
for (const sample of samples) {
counts.set(sample.size, (counts.get(sample.size) || 0) + 1);
}
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]);
if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null;
return ranked[0]?.[0] ?? null;
}
function checkFlatTypeHierarchySamples(samples) {
const byRole = new Map();
for (const sample of samples || []) {
const role = String(sample?.role || '');
const size = Math.round(Number(sample?.size) * 10) / 10;
if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue;
if (!byRole.has(role)) byRole.set(role, []);
byRole.get(role).push({ role, size });
}
const roles = [...byRole.entries()].map(([role, roleSamples]) => ({
role,
size: dominantTypeRoleSize(roleSamples),
})).filter(item => item.size !== null);
if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return [];
const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role));
let largestStep = 1;
for (let i = 1; i < sorted.length; i++) {
largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size);
}
if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return [];
const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', ');
return [{
id: 'flat-type-hierarchy',
snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`,
}];
}
function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) {
const samples = [];
for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) {
if (options.skipElement?.(el)) continue;
if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue;
const fontSize = parseFloat(getStyle(el)?.fontSize);
if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue;
samples.push({ role: typeHierarchyRole(el), size: fontSize });
}
return checkFlatTypeHierarchySamples(samples);
}
// Browser page-level checks — use document/getComputedStyle globals
function checkTypography() {
@@ -5211,28 +5296,24 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements;
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
}
const sizes = new Set();
for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
const fs = parseFloat(getComputedStyle(el).fontSize);
if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
}
if (sizes.size >= 3) {
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio < 2.0) {
findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
}
for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, {
skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'),
})) {
findings.push({ type: finding.id, detail: finding.snippet });
}
return findings;
@@ -5479,21 +5560,7 @@ function checkPageTypography(doc, win) {
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
}
// Flat type hierarchy
const sizes = new Set();
const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
for (const el of textEls) {
const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
// Filter out sub-8px values (jsdom doesn't resolve relative units properly)
if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
}
if (sizes.size >= 3) {
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio < 2.0) {
findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
}
}
findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el)));
return findings;
}
@@ -8065,14 +8132,17 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity: f.severity || ap?.severity || 'warning',
severity,
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
advisory: (ap && ap.advisory === true) || f.advisory === true,
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -8114,6 +8184,381 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -8487,18 +8932,16 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
if (matches.length === 0) return false;
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
return matches.some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { finding } from '../../findings.mjs';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
@@ -394,7 +394,7 @@ async function detectUrl(rawUrl, options = {}) {
// Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
return item;
return deriveAdvisoryFlag(item);
});
}
@@ -498,6 +498,147 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]);
}
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -545,8 +686,13 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
fmt: (m, line) => {
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -607,32 +753,6 @@ const REGEX_MATCHERS = [
];
const REGEX_ANALYZERS = [
// Flat type hierarchy
(content, filePath) => {
const sizes = new Set();
const REM = 16;
let m;
const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
while ((m = sizeRe.exec(content)) !== null) {
const px = m[2] === 'px' ? +m[1] : +m[1] * REM;
if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
}
const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
while ((m = clampRe.exec(content)) !== null) {
sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);
sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);
}
const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 };
for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }
if (sizes.size < 3) return [];
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio >= 2.0) return [];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
},
// Monotonous spacing (regex)
(content, filePath) => {
const vals = [];
@@ -1154,11 +1274,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [
function runTextContentAnalyzers(content, filePath, options = {}) {
const profile = options?.profile;
if (!shouldRunPageAnalyzers(content, filePath)) return [];
// The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS
// (single-font's removal on 2026-07-29 shifted every index down one).
// The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS.
// flat-type-hierarchy left this source-only path in issue #619 because it
// needs rendered role and usage evidence.
const findings = [];
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
const analyzer = REGEX_ANALYZERS[2 + i];
const analyzer = REGEX_ANALYZERS[1 + i];
const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];
findings.push(...profileFindings(profile, {
engine: 'regex',
@@ -1284,7 +1405,6 @@ function detectText(content, filePath, options = {}) {
// Page-level analyzers only run on full pages
if (shouldRunPageAnalyzers(content, filePath)) {
const analyzerIds = [
'flat-type-hierarchy',
'monotonous-spacing',
'em-dash-overuse',
'marketing-buzzword',
@@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = {
marginLeft: '0px',
position: 'static',
visibility: 'visible',
contentVisibility: 'visible',
opacity: '1',
top: 'auto',
right: 'auto',
@@ -9,7 +9,7 @@ import {
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { finding } from '../../findings.mjs';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
@@ -25,6 +25,7 @@ import {
checkElementOversizedH1,
checkElementQuality,
checkElementRadialSpotlight,
checkFlatTypeHierarchyFromDoc,
checkCreamPalette,
checkHtmlPatterns,
checkKickerAboveHeadingFromDoc,
@@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) {
for (const font of overusedFound) {
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
}
const sizes = new Set();
for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) {
const fontSize = parseFloat(window.getComputedStyle(el).fontSize);
if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
}
if (sizes.size >= 3) {
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio < 2.0) {
findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
}
}
findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el)));
return findings;
}
@@ -267,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default.
if (f.severity) item.severity = f.severity;
findings.push(item);
findings.push(deriveAdvisoryFlag(item));
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
@@ -4,6 +4,12 @@ function getAP(id) {
return getAntipattern(id);
}
function deriveAdvisoryFlag(item) {
if (item.severity === 'advisory') item.advisory = true;
else delete item.advisory;
return item;
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
@@ -11,8 +17,7 @@ function finding(id, filePath, snippet, line = 0) {
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
// can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules.
if (ap.advisory === true) base.advisory = true;
return base;
return deriveAdvisoryFlag(base);
}
export { getAP, finding };
export { getAP, finding, deriveAdvisoryFlag };
@@ -46,15 +46,20 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
];
function walkDir(dir) {
function walkDir(dir, onReadError = null) {
const files = [];
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full));
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
@@ -81,12 +86,19 @@ function resolveImport(specifier, fromDir, fileSet) {
return null;
}
function buildImportGraph(files) {
function buildImportGraph(files, onReadError = null) {
const fileSet = new Set(files);
const graph = new Map();
for (const file of files) {
const content = fs.readFileSync(file, 'utf-8');
let content;
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file);
const imports = new Set();
@@ -34,7 +34,7 @@ const ANTIPATTERNS = [
scopes: ['type'],
name: 'Flat type hierarchy',
description:
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.',
skillSection: 'Typography',
skillGuideline: 'flat type hierarchy',
},
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
advisory: true,
severity: 'advisory',
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -588,9 +588,10 @@ function getAntipattern(id) {
// Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect
// exit codes or the failure count, and the design hook skips them by default.
// The set is derived from the registry so a rule only needs `advisory: true`.
// `severity` is the canonical registry field. The runtime finding serializer
// derives its `advisory: true` compatibility/output flag from this set.
const ADVISORY_RULE_IDS = new Set(
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').map(rule => rule.id),
);
function isAdvisoryRule(id) {
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -688,7 +688,10 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -3911,6 +3914,88 @@ function checkElementGlow(tag, style, effectiveBg) {
// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption';
const TYPE_HIERARCHY_MIN_ROLES = 3;
const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25;
function typeHierarchyRole(el) {
const tag = String(el?.tagName || el?.nodeName || '').toLowerCase();
return /^h[1-6]$/.test(tag) ? tag : 'body';
}
function hasTextContent(el) {
return String(el?.textContent || '').trim().length > 0;
}
function isRenderedTypeElement(el, getStyle) {
for (let current = el; current; current = current.parentElement) {
const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null;
if (current.hidden || hiddenAttr) return false;
const style = getStyle(current);
if (!style) continue;
const display = String(style.display || '').toLowerCase();
const visibility = String(style.visibility || '').toLowerCase();
const contentVisibility = String(style.contentVisibility || '').toLowerCase();
if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false;
const opacity = parseFloat(style.opacity);
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
}
return true;
}
function dominantTypeRoleSize(samples) {
const counts = new Map();
for (const sample of samples) {
counts.set(sample.size, (counts.get(sample.size) || 0) + 1);
}
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]);
if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null;
return ranked[0]?.[0] ?? null;
}
function checkFlatTypeHierarchySamples(samples) {
const byRole = new Map();
for (const sample of samples || []) {
const role = String(sample?.role || '');
const size = Math.round(Number(sample?.size) * 10) / 10;
if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue;
if (!byRole.has(role)) byRole.set(role, []);
byRole.get(role).push({ role, size });
}
const roles = [...byRole.entries()].map(([role, roleSamples]) => ({
role,
size: dominantTypeRoleSize(roleSamples),
})).filter(item => item.size !== null);
if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return [];
const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role));
let largestStep = 1;
for (let i = 1; i < sorted.length; i++) {
largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size);
}
if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return [];
const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', ');
return [{
id: 'flat-type-hierarchy',
snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`,
}];
}
function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) {
const samples = [];
for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) {
if (options.skipElement?.(el)) continue;
if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue;
const fontSize = parseFloat(getStyle(el)?.fontSize);
if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue;
samples.push({ role: typeHierarchyRole(el), size: fontSize });
}
return checkFlatTypeHierarchySamples(samples);
}
// Browser page-level checks — use document/getComputedStyle globals
function checkTypography() {
@@ -3938,28 +4023,24 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements;
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
}
const sizes = new Set();
for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
const fs = parseFloat(getComputedStyle(el).fontSize);
if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
}
if (sizes.size >= 3) {
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio < 2.0) {
findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
}
for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, {
skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'),
})) {
findings.push({ type: finding.id, detail: finding.snippet });
}
return findings;
@@ -4206,21 +4287,7 @@ function checkPageTypography(doc, win) {
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
}
// Flat type hierarchy
const sizes = new Set();
const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
for (const el of textEls) {
const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
// Filter out sub-8px values (jsdom doesn't resolve relative units properly)
if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
}
if (sizes.size >= 3) {
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio < 2.0) {
findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
}
}
findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el)));
return findings;
}
@@ -5649,6 +5716,8 @@ export {
checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkFlatTypeHierarchySamples,
checkFlatTypeHierarchyFromDoc,
checkTypography,
isCardLikeDOM,
checkLayout,
@@ -138,17 +138,20 @@ export const IMMEDIATE_TIER_RULES = new Set([
// the agent is never nagged about a taste call a human might make on purpose.
// A project opts back in with `.impeccable/config.json`:
// { "detector": { "advisoryRules": "include" } }
// This set is the hook's own copy of the registry's `advisory: true` rules,
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
// self-contained and testable without loading the detector. Keep it in sync
// with the registry (cli/engine/registry/antipatterns.mjs).
// This legacy id fallback keeps older detector findings recognizable when they
// carry neither the current runtime flag nor the canonical advisory severity.
// Current findings are classified by their serialized metadata below.
export const ADVISORY_RULES = new Set([
'em-dash-overuse',
]);
export function isAdvisoryFinding(finding) {
const id = finding && normalizeIgnoreRule(finding.antipattern);
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
return Boolean(id && (
ADVISORY_RULES.has(id)
|| finding.advisory === true
|| finding.severity === 'advisory'
));
}
export const DEFAULT_CONFIG = Object.freeze({
@@ -64,6 +64,26 @@
};
}
function hasFrameworkHmrOwnership(el) {
for (let node = el; node; node = node.parentElement) {
let keys = [];
try { keys = Object.getOwnPropertyNames(node); } catch {}
if (keys.some((key) => (
key.startsWith('__reactFiber$')
|| key.startsWith('__reactProps$')
|| key.startsWith('__reactContainer$')
|| key === '_reactRootContainer'
|| key === '__vueParentComponent'
|| key === '__vue_app__'
|| key === '__vnode'
|| key === '__svelte_meta'
))) {
return true;
}
}
return false;
}
function id8() {
if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8);
return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8);
@@ -128,6 +148,7 @@
desc,
rectIsUsableAnchor,
makeFrozenAnchor,
hasFrameworkHmrOwnership,
id8,
cssId,
liveUiRoot,
@@ -71,17 +71,38 @@
return checkpointRevision;
}
function readHandledIds() {
const raw = safeRead(handledKey);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.filter(id => typeof id === 'string' && id);
}
if (typeof parsed === 'string' && parsed) return [parsed];
} catch { /* legacy values were stored as a plain session id */ }
return [raw];
}
function markHandled(id) {
if (!id) return;
safeWrite(handledKey, id);
const ids = readHandledIds().filter(existing => existing !== id);
ids.push(id);
safeWrite(handledKey, JSON.stringify(ids.slice(-8)));
}
function isHandled(id) {
return !!id && safeRead(handledKey) === id;
return !!id && readHandledIds().includes(id);
}
function clearHandled() {
safeRemove(handledKey);
function clearHandled(id) {
if (!id) {
safeRemove(handledKey);
return;
}
const remaining = readHandledIds().filter(existing => existing !== id);
if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining));
else safeRemove(handledKey);
}
function writeScrollY(y) {
+405 -163
View File
@@ -121,6 +121,10 @@
let hoveredElement = null;
let selectedElement = null;
let currentSessionId = null;
// Advances when the user begins configuring a fresh edit, before that edit
// has a server session id. Deferred recovery captures this revision so an
// older accept/discard can never reload over a replacement configuration.
let liveInteractionRevision = 0;
let expectedVariants = 0;
let arrivedVariants = 0;
let visibleVariant = 0;
@@ -188,6 +192,9 @@
// when the real accept result arrives or a new session starts.
let awaitingAcceptResult = null;
let variantObserver = null;
const discardedFrameworkWrapperWatchers = new Map();
const handledRuntimeWrapperWatchers = new Map();
const handledRuntimeWrapperReloadSessions = new Set();
let variantSelectionInFlight = false;
let variantSelectionPromise = null;
let recoveringEmptyCycling = false;
@@ -208,6 +215,7 @@
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state';
const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state';
const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload';
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
// saveSession's state updates don't clobber a carefully-captured scrollY.
@@ -270,6 +278,7 @@
desc,
rectIsUsableAnchor,
makeFrozenAnchor,
hasFrameworkHmrOwnership,
id8,
cssId,
liveUiRoot,
@@ -2034,6 +2043,16 @@
syncSteerQueueHint();
}
function beginNewLiveConfiguration() {
liveInteractionRevision += 1;
setLiveState('CONFIGURING');
}
function deferredRecoverySuperseded(sessionId, recoveryRevision) {
return liveInteractionRevision !== recoveryRevision
|| !!(currentSessionId && currentSessionId !== sessionId);
}
/** Element used to position the floating bar / shader during a session. */
function resolveBarAnchor() {
if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
@@ -5036,7 +5055,7 @@
&& el.parentElement
&& document.body.contains(el)
&& !own(el)
&& !el.closest?.('[data-impeccable-variants]');
&& !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]');
}
function elementMatchesOriginalMarkup(liveEl, origContent) {
@@ -6193,6 +6212,72 @@
showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000);
}
function isJsxSourceFile(filePath) {
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
}
function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) {
pendingVariantAnchorRetryObserver.disconnect();
pendingVariantAnchorRetryObserver = null;
}
const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
arrivedVariants = variants.length;
expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
if (arrivedVariants <= 0) {
if (state === 'GENERATING') {
// Mid-generation the source legitimately holds a scaffold wrapper
// with no variants yet (the server-side preflight wraps before the
// agent writes). Tearing the session down here would destroy an
// in-flight generation; stay in GENERATING — the variant observer
// is armed and the server re-delivers a missed `done`.
if (!opts.generationCompleted) {
console.log('[impeccable] Source has scaffold but no variants yet; still generating.');
return;
}
// Generation finished, yet the read shows only the scaffold: the
// source view is stale and no further event will fire. Re-read a
// few times before surfacing recovery — a single silent return
// here would strand the tab in GENERATING forever.
const attempt = opts.attempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
console.log('[impeccable] Generation is done but source shows no variants yet; retrying read ('
+ (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').');
setTimeout(() => {
if (state !== 'GENERATING' || currentSessionId !== sessionId) return;
if (arrivedVariants > 0) return;
injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
return;
}
}
recoverEmptyCycling('source-fallback-empty');
return;
}
const saved = loadSession();
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
? previousVisibleVariant
: (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
showVariantInDOM(sessionId, visibleVariant);
selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement;
setLiveState('CYCLING');
recoveryWaitingForAnchor = false;
hideShaderOverlay();
showOrUpdateCyclingBar();
disableInlineEdit();
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
}
/**
* No-HMR fallback: fetch the raw source file from the live server,
* parse it, extract the variant wrapper, and inject it into the live DOM.
@@ -6210,14 +6295,53 @@
return;
}
rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) {
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return;
}
// #454: never fetch/parse JSX. Missing wrap waits for mount (closed
// modal / other route). Insert scaffolds stay for late HMR. A replace
// scaffold with no variants after retries is a failed generation.
if (opts.generationCompleted && sessionId === currentSessionId) {
const attempt = opts.attempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (state !== 'GENERATING' || currentSessionId !== sessionId) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
return;
}
if (!liveWrapper) {
showToast(
"Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.",
15000,
);
return;
}
if (liveWrapper.dataset.impeccableMode !== 'insert') {
recoverEmptyCycling('source-fallback-empty');
}
return;
}
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
}
return;
}
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
fetch(url)
.then(r => { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(html => {
const parser = new DOMParser();
let srcWrapper = null;
// Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction.
const startMark = '<!-- impeccable-variants-start ' + sessionId + ' -->';
const endMark = '<!-- impeccable-variants-end ' + sessionId + ' -->';
const startIdx = html.indexOf(startMark);
@@ -6225,8 +6349,8 @@
const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx
? html.slice(startIdx + startMark.length, endIdx).trim()
: html;
const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html');
srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const doc = parser.parseFromString(block, 'text/html');
const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!srcWrapper) {
console.warn('[impeccable] Variant wrapper not found in source file.');
// A resumed cycling session whose wrapper is gone from source is an
@@ -6251,93 +6375,33 @@
return;
}
const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
const wrapper = srcWrapper.cloneNode(true);
// Wrapper already in DOM (wrap HMR landed, variant insert did not).
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
} else {
const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child');
if (!origContent) return;
const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML);
if (!liveEl) {
console.warn('[impeccable] Could not find original element in live DOM.');
enterRecoveryWaitingForAnchor({
filePath,
sessionId,
srcWrapper,
checkpointReason: 'variant_anchor_missing',
trackScroll: false,
});
return;
}
liveEl.parentElement.replaceChild(wrapper, liveEl);
}
recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) {
pendingVariantAnchorRetryObserver.disconnect();
pendingVariantAnchorRetryObserver = null;
}
// Update state: count variants, preserving the user's current variant
// when a late HMR/source reinjection lands after they have cycled.
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
arrivedVariants = variants.length;
expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
if (arrivedVariants <= 0) {
if (state === 'GENERATING') {
// Mid-generation the source legitimately holds a scaffold wrapper
// with no variants yet (the server-side preflight wraps before the
// agent writes). Tearing the session down here would destroy an
// in-flight generation; stay in GENERATING — the variant observer
// is armed and the server re-delivers a missed `done`.
if (!opts.generationCompleted) {
console.log('[impeccable] Source has scaffold but no variants yet; still generating.');
return;
}
// Generation finished, yet the read shows only the scaffold: the
// source view is stale and no further event will fire. Re-read a
// few times before surfacing recovery — a single silent return
// here would strand the tab in GENERATING forever.
const attempt = opts.attempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
console.log('[impeccable] Generation is done but source shows no variants yet; retrying read ('
+ (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').');
setTimeout(() => {
if (state !== 'GENERATING' || currentSessionId !== sessionId) return;
if (arrivedVariants > 0) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
return;
}
}
recoverEmptyCycling('source-fallback-empty');
completeSourceInjection(wrapper, sessionId, { ...opts, filePath });
return;
}
const saved = loadSession();
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
? previousVisibleVariant
: (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
showVariantInDOM(sessionId, visibleVariant);
// Update selectedElement to the visible variant's content
selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement;
const wrapper = srcWrapper.cloneNode(true);
const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child');
if (!origContent) return;
setLiveState('CYCLING');
recoveryWaitingForAnchor = false;
hideShaderOverlay();
showOrUpdateCyclingBar();
disableInlineEdit();
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML);
if (!liveEl) {
console.warn('[impeccable] Could not find original element in live DOM.');
enterRecoveryWaitingForAnchor({
filePath,
sessionId,
srcWrapper,
checkpointReason: 'variant_anchor_missing',
trackScroll: false,
});
return;
}
liveEl.parentElement.replaceChild(wrapper, liveEl);
completeSourceInjection(wrapper, sessionId, { ...opts, filePath });
})
.catch(err => {
console.error('[impeccable] Failed to fetch source:', err);
@@ -6345,44 +6409,6 @@
});
}
function normalizeSourceFallbackBlock(block, filePath) {
if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block;
return String(block)
.replace(
/<style\b([^>]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g,
(_match, attrs, css) => '<style' + attrs + '>' + css + '</style>',
)
.replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => {
const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim();
return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : '';
})
.replace(/\bclassName\s*=/g, 'class=')
.replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => {
const css = jsxStyleObjectToCss(body);
return css ? ' style="' + escapeHtml(css) + '"' : '';
});
}
function jsxStyleObjectToCss(body) {
const declarations = [];
const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g;
let match;
while ((match = re.exec(String(body || '')))) {
const prop = jsxStylePropToCss(match[1]);
const value = match[2] ?? match[3] ?? match[4] ?? '';
if (!prop || value === '') continue;
declarations.push(prop + ': ' + value);
}
return declarations.join('; ');
}
function jsxStylePropToCss(prop) {
let out = String(prop || '').trim().replace(/^["']|["']$/g, '');
if (!out) return '';
if (out.startsWith('--')) return out;
return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-');
}
function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) {
const map = new Map();
if (!sourceOriginal || !liveOriginal) return map;
@@ -6608,19 +6634,78 @@
document.getElementById(VARIANT_STATE_STYLE_ID)?.remove();
}
function discardStateStyleId(sessionId) {
return DISCARD_STATE_STYLE_ID + '-' + sessionId;
}
function showOriginalDuringDiscard(sessionId) {
if (!sessionId) return;
let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID);
let styleEl = document.getElementById(discardStateStyleId(sessionId));
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = DISCARD_STATE_STYLE_ID;
styleEl.id = discardStateStyleId(sessionId);
(document.head || document.documentElement).appendChild(styleEl);
}
styleEl.dataset.impeccableDiscardSession = sessionId;
const wrapper = '[data-impeccable-variants="' + sessionId + '"]';
styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n'
+ wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }';
}
function removeDiscardStateStylesheet(sessionId) {
if (!sessionId) return;
document.getElementById(discardStateStyleId(sessionId))?.remove();
}
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
removeDiscardStateStylesheet(sessionId);
if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild;
if (content && wrapper.parentElement) {
wrapper.parentElement.replaceChild(content, wrapper);
return;
}
wrapper.remove();
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
const selector = '[data-impeccable-variants="' + sessionId + '"]';
let observer = null;
let timer = null;
const stopWatching = function() {
observer?.disconnect();
if (timer) clearTimeout(timer);
discardedFrameworkWrapperWatchers.delete(sessionId);
};
const finishIfGone = function() {
if (document.querySelector(selector)) return false;
removeDiscardStateStylesheet(sessionId);
stopWatching();
return true;
};
if (finishIfGone()) return;
observer = new MutationObserver(finishIfGone);
observer.observe(document.body, { childList: true, subtree: true });
const resolveStillMounted = function() {
if (finishIfGone()) return;
const replacementActive = !!currentSessionId
|| (state !== 'IDLE' && state !== 'PICKING');
if (replacementActive) {
timer = setTimeout(resolveStillMounted, 12000);
discardedFrameworkWrapperWatchers.get(sessionId).timer = timer;
return;
}
removeDiscardStateStylesheet(sessionId);
stopWatching();
location.reload();
};
timer = setTimeout(resolveStillMounted, 12000);
discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer });
}
function resolveScrollLockAnchorTop() {
const anchor = resolveBarAnchor();
if (!anchor?.isConnected) return null;
@@ -7335,7 +7420,7 @@
hideInsertLine();
configureKind = 'insert';
selectedElement = placeholder;
setLiveState('CONFIGURING');
beginNewLiveConfiguration();
hideHighlight();
clearAnnotations();
showAnnotOverlay(placeholder);
@@ -7353,7 +7438,7 @@
e.preventDefault();
e.stopPropagation();
selectedElement = hoveredElement;
setLiveState('CONFIGURING');
beginNewLiveConfiguration();
showHighlight(selectedElement);
clearAnnotations();
showAnnotOverlay(selectedElement);
@@ -7530,7 +7615,7 @@
} else if (e.key === 'Enter') {
e.preventDefault();
selectedElement = hoveredElement;
setLiveState('CONFIGURING');
beginNewLiveConfiguration();
showHighlight(selectedElement);
clearAnnotations();
showAnnotOverlay(selectedElement);
@@ -8535,6 +8620,7 @@ void main() {
}
function scheduleAcceptCleanup(accepted) {
const recoveryRevision = liveInteractionRevision;
queueMicrotask(function() {
if (pendingAcceptedSession?.id !== accepted?.id) return;
// Svelte previews live in an adapter-owned mount rather than in source
@@ -8551,8 +8637,10 @@ void main() {
// races. Static servers still need a fallback, but it must not keep Live
// in SAVING or block the user's next pick.
if (!accepted?.isSvelteComponent) {
watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision);
setTimeout(function() {
if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted);
if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return;
if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision);
}, 1200);
}
}
@@ -8585,13 +8673,27 @@ void main() {
&& matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]'));
}
function ensureAcceptedDomClean(pending) {
function ensureAcceptedDomClean(pending, recoveryRevision) {
// Background cleanup for an accepted session must never mutate or reload
// a newer comparison the user has already started.
if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return;
if (acceptedDomAlreadyClean(pending)) return;
const sessionId = pending?.id;
const variantId = pending?.variant;
const wrappers = findAcceptedRuntimeWrappers(sessionId);
if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) {
// Vite can coalesce rapid scaffold/carbonize writes and leave the last
// framework-owned preview tree mounted even though source is clean. Give
// HMR another grace window, then reload from clean source rather than
// violating reconciler ownership with a manual DOM mutation.
setTimeout(function() {
if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return;
if (!acceptedDomAlreadyClean(pending)) location.reload();
}, 2000);
return;
}
if (wrappers.length === 0) {
restoreAcceptedDomFromSnapshot(pending);
restoreAcceptedDomFromSnapshot(pending, recoveryRevision);
return;
}
for (const wrapper of wrappers) {
@@ -8608,7 +8710,7 @@ void main() {
}
wrapper.remove();
}
if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending);
if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision);
}
function findAcceptedRuntimeWrappers(sessionId) {
@@ -8619,17 +8721,17 @@ void main() {
])];
}
function restoreAcceptedDomFromSnapshot(pending) {
function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) {
if (acceptedDomAlreadyClean(pending)) return;
if (!pending?.acceptedHtml) {
reloadAfterMissingAcceptedDom(pending);
reloadAfterMissingAcceptedDom(pending, recoveryRevision);
return;
}
const parent = pending.parentElement?.isConnected
? pending.parentElement
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
if (!parent) {
reloadAfterMissingAcceptedDom(pending);
reloadAfterMissingAcceptedDom(pending, recoveryRevision);
return;
}
const template = document.createElement('template');
@@ -8638,10 +8740,11 @@ void main() {
? pending.nextSibling
: null;
parent.insertBefore(template.content, anchor);
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision);
}
function reloadAfterMissingAcceptedDom(pending) {
function reloadAfterMissingAcceptedDom(pending, recoveryRevision) {
if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return;
if (acceptedDomAlreadyClean(pending)) return;
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
location.reload();
@@ -8991,14 +9094,15 @@ void main() {
return sessionState.isHandled(id);
}
function clearHandled() {
sessionState.clearHandled();
function clearHandled(sessionId) {
sessionState.clearHandled(sessionId);
}
function cleanup(options) {
const restoreOriginal = options?.restoreOriginal === true;
const instantChrome = options?.instantChrome === true;
const cleanupSessionId = currentSessionId;
const cleanupRevision = liveInteractionRevision;
clearMountErrorCard();
lastReportedMountFailure = null;
if (svelteComponentSession?.sessionId === cleanupSessionId) {
@@ -9016,19 +9120,41 @@ void main() {
else wrapper.style.display = 'none';
}
setTimeout(function() {
document.getElementById(DISCARD_STATE_STYLE_ID)?.remove();
if (!cleanupSessionId) return;
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (!lateWrapper) return;
const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]');
if (orig) {
const content = orig.firstElementChild;
if (content) {
lateWrapper.parentElement.replaceChild(content, lateWrapper);
return;
}
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
if (!cleanupSessionId) {
removeDiscardStateStylesheet();
return;
}
lateWrapper.remove();
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId);
return;
}
if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else {
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}
return;
}
if (hasFrameworkHmrOwnership(lateWrapper)) {
// As on accept, never restructure framework-owned DOM. If HMR missed
// the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race.
setTimeout(function() {
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return;
}
removeDiscardStateStylesheet(cleanupSessionId);
if (staleWrapper) location.reload();
}, 2000);
return;
}
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000);
}
hideBar(instantChrome);
@@ -9111,12 +9237,122 @@ void main() {
// Resume an active variant session after HMR/page reload.
// If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote
// variants before HMR fired. Pick up where we left off.
function resumeSession() {
function handledWrapperReloadKey(sessionId) {
return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId;
}
function clearHandledWrapperReloadStamp(sessionId) {
try {
if (sessionId) {
sessionStorage.removeItem(handledWrapperReloadKey(sessionId));
const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || '';
if (legacy === sessionId || legacy.startsWith(sessionId + ':')) {
sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY);
}
return;
}
sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY);
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const key = sessionStorage.key(i);
if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key);
}
} catch {}
}
function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) {
const sessionId = wrapper?.dataset?.impeccableVariants
|| wrapper?.dataset?.impeccableCarbonize;
if (!sessionId || !isSessionHandled(sessionId)) return false;
if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true;
if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true;
let reloadAttempts = 0;
try {
reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0;
if (reloadAttempts >= 2) return true;
sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1));
} catch {}
handledRuntimeWrapperReloadSessions.add(sessionId);
// A framework refresh can replace the variants tree with an intermediate
// carbonize tree and reload the page, cancelling the original accept timer.
// Let the file-side cleanup settle, then reload once from authoritative
// source. The sessionStorage stamp prevents a stale dev-server response
// from turning this recovery into a reload loop.
setTimeout(function() {
if (deferredRecoverySuperseded(sessionId, recoveryRevision)) {
clearHandledWrapperReloadStamp(sessionId);
handledRuntimeWrapperReloadSessions.delete(sessionId);
return;
}
const staleWrapper = document.querySelector(
'[data-impeccable-variants="' + sessionId + '"],'
+ '[data-impeccable-carbonize="' + sessionId + '"]',
);
if (staleWrapper) location.reload();
else {
clearHandledWrapperReloadStamp(sessionId);
handledRuntimeWrapperReloadSessions.delete(sessionId);
}
}, 3000);
return true;
}
function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) {
if (!sessionId || !document.body) return;
const existing = handledRuntimeWrapperWatchers.get(sessionId);
existing?.observer.disconnect();
if (existing?.timer) clearTimeout(existing.timer);
const findHandledWrapper = function() {
const wrapper = document.querySelector(
'[data-impeccable-variants="' + sessionId + '"],'
+ '[data-impeccable-carbonize="' + sessionId + '"]',
);
if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision);
};
// Vite can briefly render the clean accepted tree, then apply a delayed
// carbonize refresh after the one-shot accept fallback has already passed.
// Keep a bounded scout alive through that refresh window so a late stale
// framework tree still reloads from the now-authoritative source.
const observer = new MutationObserver(findHandledWrapper);
observer.observe(document.body, { childList: true, subtree: true });
const timer = setTimeout(function() {
if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return;
observer.disconnect();
handledRuntimeWrapperWatchers.delete(sessionId);
}, 12000);
handledRuntimeWrapperWatchers.set(sessionId, { observer, timer });
findHandledWrapper();
}
function restoreSessionSupersedingHandledWrapper(runtimeWrapper) {
const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants
|| runtimeWrapper?.dataset?.impeccableCarbonize;
if (!handledSessionId || !isSessionHandled(handledSessionId)) return false;
// Accept releases the picker before carbonize finishes, so a replacement
// generation can already be durable while the prior handled wrapper is
// still mounted. Restore that newer session before the stale-wrapper
// recovery path gets a chance to reload or consume its retry budget.
const saved = loadSession();
if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false;
if (currentSessionId === saved.id) return true;
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
}
function resumeSession(recoveryRevision = liveInteractionRevision) {
const wrapper = document.querySelector('[data-impeccable-variants]');
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
if (!wrapper) {
if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true;
clearSession();
clearHandled();
// Keep the bounded handled-id history durable. A framework can hydrate a
// completed wrapper well after initialization, and a later reload must
// still recognize that wrapper as recovery work rather than resume it.
return false;
}
@@ -9136,7 +9372,7 @@ void main() {
wrapper.remove();
if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true;
clearSession();
clearHandled();
clearHandled(sessionId);
return false;
}
@@ -12520,22 +12756,28 @@ void main() {
connectSSE();
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {
const resumed = resumeSession();
if (!resumed) {
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
// SvelteKit (and any framework that hydrates after HTML parse) may add
// the variant wrapper AFTER init runs. Watch for it and retry resume
// once it appears. Disconnect on first hit.
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
// SvelteKit, React, and other frameworks may restore a durable session
// before hydration adds its variant wrapper. Keep a deferred-wrapper scout
// whenever init did not see a runtime wrapper, even if local/server state
// was already restored successfully. Disconnect on the first wrapper hit.
if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) {
const deferredResumeRevision = liveInteractionRevision;
const scout = new MutationObserver(() => {
const wrapper = document.querySelector('[data-impeccable-variants]');
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return;
scout.disconnect();
if (resumeSession()) {
if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
}
});
scout.observe(document.body, { childList: true, subtree: true });
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING');
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
});
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(detectScript);
return;
}
+119 -3
View File
@@ -14,8 +14,9 @@
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { basename, join, resolve, dirname } from 'node:path';
import { basename, join, resolve, dirname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid
`;
}
// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter
// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts),
// so a pinned skill there shows up in `opencode debug skill` but never in the
// slash menu. The fix is a sibling `commands/impeccable-<cmd>.md` that uses the
// OpenCode command schema (description, agent, subtask). Body loads the skill
// via the skill tool and then the sub-command's reference file directly, so
// /impeccable-<cmd> runs the same workflow /impeccable <cmd> routes to.
const OPENCODE_PIN_MARKER = '<!-- impeccable-pinned-command -->';
function generatePinnedOpencodeCommand(command, metadata) {
const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`;
return `---
description: "${desc}"
agent: build
subtask: true
---
${OPENCODE_PIN_MARKER}
Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node <skill-base-dir>/scripts/context.mjs\`, then load \`<skill-base-dir>/reference/${command}.md\` and follow it. \`<skill-base-dir>\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything.
$ARGUMENTS
`;
}
// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir
// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode →
// ~/.config/opencode); duplicated here because this script ships inside the
// installed skill and cannot import the CLI.
function opencodeUserConfigDir() {
if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR;
if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode');
return join(homedir(), '.config', 'opencode');
}
/**
* Resolve every commands dir that should receive an OpenCode pin: the
* project-local dir when the project has the skill, plus the user config dir
* when Impeccable is installed globally (#406 layout). A user-scope skill is
* visible from every project, so its pinned commands belong next to it.
* With `forCleanup`, both commands dirs are included even when the skill is
* gone, so unpin can still reach a pin left behind by a removed install;
* removal stays safe because removePinnedOpencodeCommand is marker-guarded.
*/
function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) {
const dirs = [];
const seen = new Set();
const push = (commandsDir) => {
const key = resolve(commandsDir);
if (!seen.has(key)) {
seen.add(key);
dirs.push(commandsDir);
}
};
if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) {
push(join(projectRoot, '.opencode', 'commands'));
}
const userConfig = opencodeUserConfigDir();
if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) {
push(join(userConfig, 'commands'));
}
return dirs;
}
function writePinnedOpencodeCommand(commandsDir, command, metadata) {
const commandFile = join(commandsDir, `impeccable-${command}.md`);
if (existsSync(commandFile)) {
const existing = readFileSync(commandFile, 'utf-8');
if (!existing.includes(OPENCODE_PIN_MARKER)) {
console.log(` SKIP: ${commandFile} (non-pinned command already exists)`);
return false;
}
} else {
mkdirSync(commandsDir, { recursive: true });
}
writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata));
console.log(` + ${commandFile}`);
return true;
}
function removePinnedOpencodeCommand(commandsDir, command) {
const commandFile = join(commandsDir, `impeccable-${command}.md`);
if (!existsSync(commandFile)) return false;
const content = readFileSync(commandFile, 'utf-8');
if (!content.includes(OPENCODE_PIN_MARKER)) {
console.log(` SKIP: ${commandFile} (not a pinned command)`);
return false;
}
rmSync(commandFile, { force: true });
console.log(` - ${commandFile}`);
return true;
}
/**
* Pin a command: create shortcut skill in all harness dirs.
*/
function pin(command, projectRoot) {
const metadata = loadCommandMetadata();
const harnessDirs = findHarnessDirs(projectRoot);
const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot);
if (harnessDirs.length === 0) {
if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) {
console.log('No harness directories with impeccable installed found.');
return false;
}
let created = 0;
// OpenCode is handled separately below because its shortcut format is a
// slash command, not a SKILL.md. Excluding it from the skill loop here
// prevents a duplicate `.opencode/skills/<cmd>/SKILL.md` that OpenCode
// would never surface as `/<cmd>`.
for (const skillsDir of harnessDirs) {
if (skillsDir.includes(`${sep}.opencode${sep}`)) continue;
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$');
// Check if skill already exists (and isn't a pin)
@@ -151,6 +250,12 @@ function pin(command, projectRoot) {
created++;
}
// OpenCode: write a slash command bridge, not a skill shortcut. Covers both
// project installs and user-scope (global config) installs.
for (const commandsDir of opencodeCommandsDirs) {
if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++;
}
if (created > 0) {
console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`);
console.log('Use the pinned command directly in each harness.');
@@ -160,13 +265,17 @@ function pin(command, projectRoot) {
}
/**
* Unpin a command: remove shortcut skill from all harness dirs.
* Unpin a command: remove shortcut skill in all harness dirs.
*/
function unpin(command, projectRoot) {
const harnessDirs = findHarnessDirs(projectRoot);
let removed = 0;
// OpenCode has its own cleanup path below; skip the skill loop here so a
// stray `.opencode/skills/<cmd>/SKILL.md` written by an older Impeccable
// version is never silently dropped here.
for (const skillsDir of harnessDirs) {
if (skillsDir.includes(`${sep}.opencode${sep}`)) continue;
const skillDir = join(skillsDir, command);
if (!existsSync(skillDir)) continue;
@@ -185,6 +294,13 @@ function unpin(command, projectRoot) {
removed++;
}
// OpenCode: remove the pinned command file if it's one of ours, in every
// scope it could have been written to — even when the skill itself is
// already gone, since removal is marker-guarded.
for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) {
if (removePinnedOpencodeCommand(commandsDir, command)) removed++;
}
if (removed > 0) {
console.log(`\nUnpinned '${command}' from ${removed} location(s).`);
console.log(`Use Impeccable's '${command}' workflow directly to access it.`);