mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
138
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53a6947653 | ||
|
|
cb305fdca1 | ||
|
|
e91c273361 | ||
|
|
e867487d55 | ||
|
|
248a4a699a | ||
|
|
62e90a257f | ||
|
|
ae388ac58f | ||
|
|
ac0416b655 | ||
|
|
c0b9b6f95a | ||
|
|
dc4e4a4bd6 | ||
|
|
251135e190 | ||
|
|
aee5ddd10c | ||
|
|
8e62ab47ba | ||
|
|
5f7b001cbe | ||
|
|
2ab054d1f4 | ||
|
|
7fa695093e | ||
|
|
63fb8a56f9 | ||
|
|
0dd4f90af6 | ||
|
|
ab9a29728b | ||
|
|
29e5b1494c | ||
|
|
181212cb2d | ||
|
|
c38ad8fb8b | ||
|
|
19786e7a22 | ||
|
|
1cbee026c3 | ||
|
|
045865918a | ||
|
|
5c8652b019 | ||
|
|
490dcfd678 | ||
|
|
4596f3183c | ||
|
|
ddf4526fb5 | ||
|
|
628aac5a40 | ||
|
|
477484aaee | ||
|
|
5d10bc842c | ||
|
|
fc05472a20 | ||
|
|
f254e7685d | ||
|
|
dbff0880e6 | ||
|
|
d65a08b064 | ||
|
|
c70bcbf6b4 | ||
|
|
aee6ce9352 | ||
|
|
a075d89bdb | ||
|
|
e76b3424d2 | ||
|
|
e46e0da885 | ||
|
|
b14df98183 | ||
|
|
6886ab8c0e | ||
|
|
ae5e95101a | ||
|
|
a37b3f6b02 | ||
|
|
d086837dfc | ||
|
|
80e4dd0d58 | ||
|
|
2f609915eb | ||
|
|
ebaf9f1d5b | ||
|
|
d417ff1f01 | ||
|
|
e15d8e122f | ||
|
|
3b35161000 | ||
|
|
ca7981f669 | ||
|
|
620ba1fe7d | ||
|
|
1045c6ca98 | ||
|
|
d28dbc7a8d | ||
|
|
667095d216 | ||
|
|
14d2641685 | ||
|
|
e2761cae80 | ||
|
|
85f84bf620 | ||
|
|
1a3f588c71 | ||
|
|
731cd2e6cd | ||
|
|
b33feacbe9 | ||
|
|
df09de3676 | ||
|
|
de7b72843f | ||
|
|
d6a9891066 | ||
|
|
6d2af3f800 | ||
|
|
69b63d36b6 | ||
|
|
baa76cfd05 | ||
|
|
71ccba9f5b | ||
|
|
d69bd093f9 | ||
|
|
5dfeba6d3e | ||
|
|
2345868c7b | ||
|
|
57ce11288f | ||
|
|
707794c597 | ||
|
|
ae118ebf57 | ||
|
|
3125864d1a | ||
|
|
dd0279b6bd | ||
|
|
b32a02d02f | ||
|
|
9529f07840 | ||
|
|
ad30c67dca | ||
|
|
7d6109b723 | ||
|
|
33b9a3752b | ||
|
|
bc51310dad | ||
|
|
0c9ce16248 | ||
|
|
ae2be34fdc | ||
|
|
8cf362b6fe | ||
|
|
bcfb7efc46 | ||
|
|
62a2026afc | ||
|
|
c90faaab55 | ||
|
|
febce52e8d | ||
|
|
c5e1ddd054 | ||
|
|
ae03e9e09c | ||
|
|
60c860f022 | ||
|
|
675656c18c | ||
|
|
0f80c1f5aa | ||
|
|
f2f73edb33 | ||
|
|
b1c5707fde | ||
|
|
af56fae571 | ||
|
|
1052f6c3a4 | ||
|
|
4f10aed4ba | ||
|
|
b89b4c41d3 | ||
|
|
1d4b98ea01 | ||
|
|
68f13a225e | ||
|
|
65a5197439 | ||
|
|
3a2d3a9c42 | ||
|
|
c91f3717d4 | ||
|
|
a3d7b247aa | ||
|
|
c91047d66a | ||
|
|
c5eb38a381 | ||
|
|
c83a7eced2 | ||
|
|
33f824b5b5 | ||
|
|
463ba38860 | ||
|
|
358fc2e716 | ||
|
|
1b4a0b9bac | ||
|
|
2d489f898b | ||
|
|
1efdff9aea | ||
|
|
0a3c12f78e | ||
|
|
bf957452c4 | ||
|
|
32930818a1 | ||
|
|
08c2323b51 | ||
|
|
dc5d9baa0e | ||
|
|
7b202c3223 | ||
|
|
24a014ddcf | ||
|
|
166e4481e1 | ||
|
|
f72fcad7d6 | ||
|
|
827dfeb95e | ||
|
|
5b2df20b85 | ||
|
|
19b5fa40d0 | ||
|
|
42bc53eac9 | ||
|
|
38d2c393dc | ||
|
|
f274ca2c01 | ||
|
|
de9d543825 | ||
|
|
7a0489bd91 | ||
|
|
a209eeb0bd | ||
|
|
6b342244e9 | ||
|
|
adc798debb | ||
|
|
9d1b4bdfac |
@@ -9,11 +9,11 @@ This skill gives you the tools and permission to create design that earns to be
|
||||
Core principles:
|
||||
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
|
||||
- Dream big and bold. Distinct, beautiful, outstanding and highly inspiring work.
|
||||
- Verify in bounded passes, not a loop, and the ceiling covers the whole cycle: screenshots, defect scans, micro-edits, and rebuilds alike. Build fully, inspect once with a batched round (desktop and mobile together), fix everything it shows in one batch, confirm with at most one more round, and stop polishing. Open-ended self-QA burns the user's money doing worse what the finish handoffs do better.
|
||||
- Verify in bounded passes, not a loop, and the ceiling covers the whole cycle: screenshots, defect scans, micro-edits, and rebuilds alike. Build fully, inspect once with a batched round (desktop and mobile together on the web; the shipped device classes on a native platform), fix everything it shows in one batch, confirm with at most one more round, and stop polishing. Open-ended self-QA burns the user's money doing worse what the finish handoffs do better.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session (if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs`; keep cwd at the user's project). 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.
|
||||
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 .agents/skills/impeccable/scripts/...` command in this skill and its references, and `.agents/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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ Your job is production cleanup, not new art direction. Work only from the approv
|
||||
|
||||
Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster.
|
||||
|
||||
## Decision Sketches
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one sketch: one card, one file, written to the card's declared `sketch` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a sketch is reported back, not padded from imagination. Render through the parent's shared frame, including its aspect: the requested surface's first viewport as a flat, matte design sketch in the card's own palette and type character, deliberately unfinished, no photorealism, no gloss; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. The frame is shared across siblings so no sketch looks more finished than another; a finish gap breaks the comparison. The only legible text is the product's real name and one real headline; greek every other text region into indistinct lines, because an invented spec, price, or date in a sketch is a claim PRODUCT.md never made. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a sketch run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
@@ -56,7 +56,7 @@ Ask blockers once, globally. Missing source path/crops or output directory block
|
||||
Codex: the imagegen skill's built-in `image_gen` path is the native tool here; prefer it for generation, editing, and the chroma-key workflow.
|
||||
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 {{scripts_path}}/embed-prompt.mjs <asset> --prompt "<the prompt used>"` so the prompt is embedded in the image itself, because the build thread composes what you made and needs to know what it is looking at, and the embedding survives copies where sidecars get lost.
|
||||
9. Save outputs non-destructively in the requested project directory, and leave the intent with the file: after every generation, run `node .agents/skills/impeccable/scripts/embed-prompt.mjs <asset> --prompt "<the prompt used>"` so the prompt is embedded in the image itself, because the build thread composes what you made and needs to know what it is looking at, and the embedding survives copies where sidecars get lost.
|
||||
10. Compare each output against its source crop, opening every image by its workspace-relative path; sandboxed viewers reject absolute paths. If a review/QA tool is available, run it before the final manifest, then retry each major/fatal finding once before finalizing.
|
||||
|
||||
Use `texture/pattern extraction` only when the source region is already clean enough to sample as texture. If UI, cards, labels, headings, body copy, or footer chrome must be removed to make a reusable texture or background, classify it as crop-derived cleanup or clean-plate work.
|
||||
|
||||
@@ -13,12 +13,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
|
||||
|
||||
## Input Contract
|
||||
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); desktop and mobile screenshot paths captured by the parent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and the approved comp path; and the skill's `reference/craft-floor.md` path. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, which live in `.impeccable/review/` (on the web, `desktop.png` and `mobile.png`; on 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, and `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and, on a comp-led build, the approved comp path (a code-led build has no approved comp; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing in this file 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 also carries 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 and judge every check in the platform's own conventions, the screenshots are device captures rather than browser viewports, and your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
|
||||
|
||||
## Checks, in order
|
||||
|
||||
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
|
||||
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <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. 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, and that is a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and they 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, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. A critique-reference comp, when one arrived on such a build, is provocation rather than spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is the question of what the image dared that the build did not, and the 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. In every material_fixes list, 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 and that is a material fix ahead of any craft point. Then, for each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport.
|
||||
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
|
||||
@@ -36,5 +36,5 @@ Return the disposition line first, then exactly five sections: `persistence` (pa
|
||||
|
||||
## Verdict Pass
|
||||
|
||||
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
|
||||
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent recaptures over the same screenshot files you read in the review round, so re-read those exact paths for this round; a round-stamped filename you invent points at nothing. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
|
||||
'''
|
||||
|
||||
@@ -38,3 +38,9 @@ Would a fluent Android user trust this app, or trip on off-spec components? The
|
||||
- **One FAB, one primary action.** Never stack FABs or spend one on a secondary task.
|
||||
- **Snackbars for transient feedback** (actionable when useful, never a toast for that); dialogs only for decisions that must interrupt.
|
||||
- **Material motion patterns.** Container transform, shared-axis, fade-through, with standard easing and durations; honor the system Remove animations setting with a crossfade or instant cut.
|
||||
|
||||
## Verifying the build
|
||||
|
||||
- **Screenshots come from the emulator or a connected device, never a browser.** Build and install, then capture with `adb exec-out screencap -p > <path>` (pick a device with `adb -s <serial>` when several are attached). Capture every device class the app ships to, at least one phone and, when tablets are a target, one tablet, and write the files where the review flow expects them.
|
||||
- **Dark theme and font scale belong in the pass.** `adb shell cmd uimode night yes` flips the theme; `adb shell settings put system font_scale 1.3` (restore `1.0` after) catches the clipped labels a fixed layout hides; with several targets attached, the capture's `-s <serial>` goes on these commands too.
|
||||
- **Emulators give breadth; gestures, refresh rates, and performance need hardware.** Say which one produced the evidence.
|
||||
|
||||
@@ -74,12 +74,15 @@ Keep content visible in the default state so failed scripts do not hide the page
|
||||
|
||||
Respect autoplay and sound preferences. Any nonessential loop must stop when offscreen or hidden.
|
||||
|
||||
Every web animation needs a `prefers-reduced-motion` path with an intentional alternative. Remove or reduce spatial movement while preserving opacity, color, and state transitions that carry meaning. Reduced motion means fewer and gentler animations, not disabling all motion; feedback that confirms an action should remain legible.
|
||||
|
||||
## Verify
|
||||
|
||||
- The focal motion is specific to the selected world and surface.
|
||||
- Every supporting animation explains feedback, state, or relationship.
|
||||
- Interruption and repeated use behave correctly.
|
||||
- Desktop, mobile, and keyboard paths remain usable.
|
||||
- The `prefers-reduced-motion` path reduces movement without erasing meaningful feedback or state changes.
|
||||
- Expensive effects stay smooth on the target device.
|
||||
- Removing an animation would lose meaning or authored character, not merely decoration.
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
> **Additional context needed**: which section is the target, and what must stay untouched.
|
||||
|
||||
An open direction round owns the word first: "bolder" said while a direction decision is on the table is the Bolder hand register steer, a fresh deal of foreign forms (see new-work.md), not this command. This command refines a surface whose world already shipped.
|
||||
|
||||
"Bolder" is an amplification request, and almost always it is scoped to something that already exists. The surrounding page, its system, and its conventions are the given. Your job is to raise one part to the conviction the rest already implies, without rebuilding anything the brief did not name. The reflex answer, reaching for more effects, is the opposite of bold; reject it first.
|
||||
|
||||
## Scope is sovereign
|
||||
|
||||
@@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
|
||||
- **Type:** body measure 65–75ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
|
||||
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
|
||||
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
|
||||
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
|
||||
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
|
||||
- **Coverage:** every brief requirement present and findable within seconds.
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ Your job is production cleanup, not new art direction. Work only from the approv
|
||||
|
||||
Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster.
|
||||
|
||||
## Decision Sketches
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one sketch: one card, one file, written to the card's declared `sketch` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a sketch is reported back, not padded from imagination. Render through the parent's shared frame, including its aspect: the requested surface's first viewport as a flat, matte design sketch in the card's own palette and type character, deliberately unfinished, no photorealism, no gloss; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. The frame is shared across siblings so no sketch looks more finished than another; a finish gap breaks the comparison. The only legible text is the product's real name and one real headline; greek every other text region into indistinct lines, because an invented spec, price, or date in a sketch is a claim PRODUCT.md never made. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a sketch run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
|
||||
|
||||
## Input Contract
|
||||
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); desktop and mobile screenshot paths captured by the parent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and the approved comp path; and the skill's `reference/craft-floor.md` path. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, which live in `.impeccable/review/` (on the web, `desktop.png` and `mobile.png`; on 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, and `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and, on a comp-led build, the approved comp path (a code-led build has no approved comp; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing in this file 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 also carries 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 and judge every check in the platform's own conventions, the screenshots are device captures rather than browser viewports, and your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
|
||||
|
||||
## Checks, in order
|
||||
|
||||
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
|
||||
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <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. 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, and that is a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and they 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, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. A critique-reference comp, when one arrived on such a build, is provocation rather than spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is the question of what the image dared that the build did not, and the 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. In every material_fixes list, 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 and that is a material fix ahead of any craft point. Then, for each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport.
|
||||
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
|
||||
@@ -34,4 +34,4 @@ Return the disposition line first, then exactly five sections: `persistence` (pa
|
||||
|
||||
## Verdict Pass
|
||||
|
||||
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
|
||||
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent recaptures over the same screenshot files you read in the review round, so re-read those exact paths for this round; a round-stamped filename you invent points at nothing. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
|
||||
@@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`.
|
||||
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
|
||||
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
|
||||
|
||||
## Intentional findings
|
||||
## Triage findings
|
||||
|
||||
The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`.
|
||||
The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes:
|
||||
|
||||
- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through.
|
||||
- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `"<who decided: evidence>"`; write "user confirmed" only when the user actually did.
|
||||
- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit.
|
||||
|
||||
Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first.
|
||||
|
||||
Prefer the narrowest exception:
|
||||
|
||||
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding line shows an `ignore-value <rule> <value>` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default.
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value <id> "*" --file <path>`. Run `npx impeccable detect <path>` first to see what actually fires there.
|
||||
- Reach for `ignore-file <path>` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
@@ -67,10 +73,10 @@ Example value-specific exception:
|
||||
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
|
||||
```
|
||||
|
||||
Example intentional motion exception:
|
||||
Example self-served exception, with the evidence named:
|
||||
|
||||
```bash
|
||||
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
|
||||
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject"
|
||||
```
|
||||
|
||||
Example whole-rule font exception:
|
||||
|
||||
@@ -43,3 +43,9 @@ Would a fluent iPhone user trust this app, or pause at off-spec controls? The te
|
||||
|
||||
- **System transitions.** Push slides, sheets rise, dismiss reverses the entrance. Custom transitions that fight the navigation model disorient.
|
||||
- **Honor Reduce Motion.** Crossfade instead of parallax and large slides.
|
||||
|
||||
## Verifying the build
|
||||
|
||||
- **Screenshots come from the Simulator, never a browser.** Build and run, then capture with `xcrun simctl io booted screenshot <path>` (with several running, replace `booted` with the target's UDID from `xcrun simctl list devices booted`; display names can collide, the UDID never does). Capture every device class the app ships to, at least one iPhone and, when iPad is a target, one iPad, and write the files where the review flow expects them.
|
||||
- **Dark Mode and Dynamic Type belong in the pass.** `xcrun simctl ui booted appearance dark` flips appearance, reusing the capture's UDID when several are booted; a check at a large Dynamic Type size catches the truncation a fixed layout hides.
|
||||
- **Simulators give breadth; posture, gestures, and performance need hardware.** Say which one produced the evidence.
|
||||
|
||||
@@ -43,12 +43,14 @@ The script assigns which structure gets built; your top-ranked structure is what
|
||||
1. Name the product's unique mechanism in one sentence, the audience's real scene, its cultural home, and what this first surface must prove. Note the page this category always ships and its predictable opposite; name both as the rut and keep them out of the seven-candidate list. A brief that paints its own picture, a product name, a titled artifact, a governing metaphor, adds its literal reading to the rut: spend at most one candidate on it and derive the rest from elsewhere in the audience's world.
|
||||
2. From that cultural world, list seven concrete visual systems, artifacts, places, or rituals the audience knows by heart, each with one line on why it resonates and can carry the mechanism, ordered by resonance. The audience's world includes its graphic and screen traditions, not only its physical objects: the notation, publications, identity programs, data graphics, and interfaces it reads daily; a nameable abstract system (a school of poster, a documentation standard) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; dig until the list spans at least three families.
|
||||
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
|
||||
4. Run `node .agents/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: 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, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and 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, and beating a thin or tool-monoculture list is the point.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the hand's challengers as named alternates, the weighing's verdict written on each as its one-line case, an honest "fuses poorly because X" included; the weighing informs the user's choice, it never pre-empts it. A hand holds at most three challengers: when the roll deals more, the three strongest join the hand 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 re-roll with an optional one-line steer. Never present a ranked menu of your own grounded candidates; a lineup of those invites the safest card. 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; the structured tool's option list also carries the standing exit as its last option.
|
||||
4. Run `node .agents/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: 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, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and 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, and beating a thin or tool-monoculture list is the point. The weighing closes with a verdict per challenger, decided before any borrowing is considered: wins (beats the assigned direction on both axes; it becomes the build candidate), competitive (holds one axis; it 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 motif lifted from a declined world 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, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand 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 MY PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, 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: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A 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; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
|
||||
|
||||
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, in 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. A standing preference gets recorded 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. You may 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, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; 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 (run the script with `--schema` for the exact shape); the page renders identity from these fields, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now 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. Exit 4 means the page was closed without an answer: 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 instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as 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, in 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. A standing preference gets recorded 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. You may 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 and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `followup: true` when the execution-contract round will follow (it does whenever image generation exists and no standing build-path preference is recorded); 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 (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now 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. Exit 4 means the page was closed without an answer: 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 instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
|
||||
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, the canon card included. Serve the page first, then produce the sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
|
||||
When image generation exists, every card also declares a `sketch` path under `.impeccable/mocks/decision/` (the field keeps its wire name for compatibility; what it carries is the card's comp), 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, produced under the comp discipline in [visualize.md](visualize.md): 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 sketch pays sketch quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, 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. When the harness runs subagents in parallel, fan the set out as 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 at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading 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: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the 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.
|
||||
|
||||
The moment the direction lands, one more round on the same open table decides the execution contract. The direction payload declares `followup: true`, so the table stays open after the pick; deliver the build-path payload through `--update` immediately. Two text-only cards. **Comp-led**: a first-viewport comp is generated and it is law, the finish review audits the build against it; boldest composition on the table, fix rounds expected, motion at risk; choosing it makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. Lead with the chosen world's fit: a costume-heavy catalog world leads comp-led, a quiet or conventional direction leads code-led; the lead is a default, never a decision, and the user flips it freely. A standing preference, voiced once, is recorded as a brand commitment in PRODUCT.md and skips this round on later surfaces. Without image generation there is no fork and no round: code-led is the only path, stated in one line rather than asked. Only a detached table (`--start`) stays open for `--update`: a blocking serve or the structured-tool channel runs the build-path round as its own second question instead, and `followup: true` belongs only on a detached round.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
|
||||
|
||||
@@ -78,15 +80,18 @@ If the work establishes durable strategy for a route or artifact, read its exist
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
Whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options rendered and put before the user for approval. This step is proven to produce the most compositional and ambitious work.
|
||||
On a comp-led build, whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options put before the user for approval, the chosen card's decision comp plus two variations. This step is proven to produce the most compositional and ambitious work. On a code-led build the comp round is skipped by contract, never by drift: the ambition it would have carried lives in the direction contract's FIRST VIEWPORT block and named signature interaction, and the finish reviewer audits those promises in behavior.
|
||||
|
||||
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
|
||||
|
||||
## 6. Build with full commitment
|
||||
|
||||
When an approved comp exists, the comp is king, and the build happens in phases. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses.
|
||||
|
||||
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
|
||||
|
||||
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
|
||||
- **Prove the hero before building past it.** When an approved comp exists, render the first viewport, capture it, and set it beside the comp's first viewport before any later section: the hero carries the run's ambition, and every following section inherits its shortfall. Judge scale and density as quantities, a field at a tenth of the comp's coverage or type at half its weight is a different design, and a five-minute retry here is what a rebuild verdict at the finish costs when this check is skipped.
|
||||
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
|
||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
|
||||
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
|
||||
@@ -98,8 +103,8 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
||||
|
||||
## 7. Inspect and finish
|
||||
|
||||
Inspect desktop and mobile in one batched screenshot round, critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
|
||||
Inspect the surface's target sizes in one batched screenshot round: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes per OS, captured from the simulator or emulator the way the platform reference's Verifying the build section describes. Critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
|
||||
|
||||
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. Where this harness runs no design hook, run `node .agents/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch: put that verdict in front of the user with the named comp regions and let them choose between a re-derivation and shipping as it stands. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
|
||||
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. On the web, where this harness runs no design hook, run `node .agents/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless web build that skips this ships every tell the hook exists to catch. A native platform skips the detector entirely: it reads HTML and CSS and has no verdict on native code, so the reviewer's floor check is the only slop gate and the input packet says so. Capture the screenshots into `.impeccable/review/`, one file per captured viewport (on the web, `desktop.png` and `mobile.png`; on native, one per device class, such as `phone.png` and `tablet.png`, suffixed per OS on adaptive), creating that directory when the harness does not; the paths you pass the reviewer are its spec, and that directory is where it looks when a passed path is missing. Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths (on a code-led build there is no approved comp; the chosen decision comp rides in that slot as the critique reference, named as such), the craft-floor reference path, and on a native platform the platform reference path(s), [ios.md](ios.md) / [android.md](android.md), both on adaptive, plus one line saying no detector ran, so the reviewer judges in the platform's conventions rather than the web's. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a verdict, telling the user what is happening rather than asking permission to fix a failure. The user is consulted only when a second rebuild directive arrives, both verdicts on the table, or when rebuilding would discard content the user approved. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports over the same files. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
|
||||
|
||||
Then spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, the artifact path, the direction contract, PRODUCT.md, the [document.md](document.md) reference path, and the boundary to write at; it records DESIGN.md and the sidecar from the built world, ground truth over intention; without subagents the pass runs from [degraded/documenter.md](degraded/documenter.md). A clean detector pass is not finished; finished is the contract kept, the comp honored, the review closed, and the system recorded.
|
||||
|
||||
@@ -19,7 +19,7 @@ Fix the cause at the narrowest correct level. Ask when a binding system principl
|
||||
|
||||
## 2. Gather the evidence
|
||||
|
||||
Use the feature yourself at representative desktop and mobile sizes. Determine:
|
||||
Use the feature yourself at the surface's representative sizes: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes on the simulator, emulator, or hardware, captured per the platform reference's Verifying the build section. Determine:
|
||||
|
||||
- whether the path is functionally complete;
|
||||
- the intended quality bar and time available;
|
||||
@@ -86,10 +86,10 @@ Do not perfect one corner while leaving the rest below the same quality bar.
|
||||
|
||||
Walk the complete path again with mouse, keyboard, and touch where applicable. Check:
|
||||
|
||||
- mobile, intermediate, and wide layouts;
|
||||
- mobile, intermediate, and wide layouts on the web; phone and tablet size classes in both supported orientations on native;
|
||||
- loading, empty, error, success, disabled, long-content, and missing-content states;
|
||||
- zoom, contrast, focus, semantics, and screen-reader names;
|
||||
- console errors, layout shift, interaction latency, image loading, and supported browsers;
|
||||
- console errors, layout shift, interaction latency, and image loading everywhere; supported browsers on the web; supported OS versions, runtime warnings, and dropped frames on native;
|
||||
- agreement with DESIGN.md, neighboring features, and the user's scope.
|
||||
|
||||
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Visualize: Direction Comps & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate three compositional options
|
||||
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. A decision-page sketch is not a probe: it chose the direction at deliberately unfinished fidelity, so the three comps render regardless, and the chosen card's sketch seeds at most one of them.
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here.
|
||||
|
||||
- A comp is a designed surface, not a picture of the subject. Lead the generation prompt with the surface's own structure, whatever regions this design actually has, named in order with their scale relationships; a page with no navigation states that instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with the world's atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph or scene with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally.
|
||||
- When the user shortlisted multiple concepts, spread the three across them.
|
||||
@@ -22,17 +22,17 @@ Show the three together: in the harness when it can display images, otherwise on
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is still recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats a build with generated comps and no recorded approval as carrying a material finding.
|
||||
This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is still recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats a build whose comp round produced comps with no recorded approval as carrying a material finding; decision comps under `.impeccable/mocks/decision/` are the direction round's hand, not comp-round output, and imply no approval on their own.
|
||||
|
||||
After approval, record the choice where tools can find it: the approved comp's path goes in the surface brief, and the approved comp's `.json` prompt sidecar gains `"approved": true` (every comp generated through `generate-image.mjs` has one; create it if a native tool didn't). The sidecar travels with the mocks folder, so the approval survives sessions and machines that never see the brief. Then summarize the composition and the parts of the comp that must not be literalized, return to new-work.md, record the direction contract from the approved surface concept, and build.
|
||||
|
||||
## Inventory implementation fidelity
|
||||
|
||||
Before building, inventory the approved comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same written inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
|
||||
Before building, read the approved comp as a design system and record it in the brief: component grammar, corner language, line weights, elevation treatment, and the type ramp, because everything the comp does not show gets built from this record, and without it the fallback is the model's stock kit of square boxes, 1px grids, bento cells, and hard shadows. Then inventory the comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same written inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. The primary action gets its own row with its own medium: when the comp dissolves, stamps, erodes, or otherwise physically works the main CTA, that treatment is signature material on the page's most important element, and shrinking it to a border trick or a few decorative pixels is the compliance-token version of commitment. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
|
||||
|
||||
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack; writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish, is not a medium choice, it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly, diagrams with countable elements, controls, flat shape systems, and it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region instead of producing it is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity, so "no photography on hand" forbids fake proof, not an illustrated hero.
|
||||
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack, and so is any texture by that name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient or layered background is not a texture medium and "layered CSS textures" is not a medium at all. Writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish or a cotton field's weave, is not a medium choice, it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly, diagrams with countable elements, controls, flat shape systems, and it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region instead of producing it is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity, so "no photography on hand" forbids fake proof, not an illustrated hero.
|
||||
|
||||
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond, and code executed safely and professionally remains first-class there. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
|
||||
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond, and code executed safely and professionally remains first-class there. A field or texture built from many small elements carries a quantity commitment either way: write down its approximate density and coverage ("thousands of glyphs over two-thirds of the fold, dense at the top fading into the path"), because a field rebuilt at a tenth of its density passes every checklist and still is not the design. TYPE rows carry the same discipline: name the face's compression class, and render one headline word against the comp before building on it; a visibly wider or lighter silhouette means the face is wrong, and every section built on it inherits the miss. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
|
||||
|
||||
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies.
|
||||
|
||||
@@ -42,6 +42,8 @@ Treat the comp as a north star, not something to trace, and know what that allow
|
||||
|
||||
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .agents/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
|
||||
|
||||
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
|
||||
When the harness runs subagents, spawn the shipped asset producer every time, even when the inventory's produce bucket looks empty: its manifest is the independent second opinion on your media, and runs that skipped the spawn are the runs whose cotton became CSS. An honestly empty manifest costs one cheap spawn; a wrongly empty produce bucket costs the build its materials. Use the producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
|
||||
|
||||
Convert images with a converter context.mjs reported at boot (the IMAGE_TOOLS line); probe only when it reported none, at most once per session, never per image.
|
||||
|
||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||
|
||||
@@ -31,6 +31,16 @@
|
||||
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
|
||||
* fresh assigned index, challengers, and compositions. One base key therefore
|
||||
* reproduces the entire chain of rounds.
|
||||
* - REGISTER (--register safer|bolder): the user's steering on the
|
||||
* familiar-to-bold axis, applied to a re-roll round. A register changes
|
||||
* only what this round instructs, never what it dealt: the same key and
|
||||
* reroll count reproduce the same deal whatever the register, so the
|
||||
* exclusion chain never forks. bolder presents the dealt foreign forms
|
||||
* as the whole hand (first-dealt leads, dice-assigned by deal order);
|
||||
* safer spends the dealt hand unseen and presents the familiar register,
|
||||
* the model's conventional grounded candidates plus the canon against
|
||||
* named competitors, the one sanctioned lineup of the model's own list.
|
||||
* Registers are user-requested, never pre-selected by the model.
|
||||
* - RATINGS: the reviewer's approval ratings weight the challenger draw
|
||||
* (3-star doubles the odds, 1-star sits out); the approved pool itself
|
||||
* is unchanged.
|
||||
@@ -41,7 +51,9 @@
|
||||
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
|
||||
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
|
||||
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
|
||||
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --scope direction
|
||||
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1 --register bolder
|
||||
* node scripts/concept-seed.mjs --chosen <challenger-id> --kind challenger --from <key> --scope direction
|
||||
* node scripts/concept-seed.mjs --kind assigned --from <key> --scope direction
|
||||
*
|
||||
* --grain names how much of the product is in play: product, flow, view, or
|
||||
* region. A docs site, an onboarding flow, a landing page and a data table are
|
||||
@@ -62,8 +74,13 @@
|
||||
* Challenger data resolves in order: a local catalog directory (the private
|
||||
* service repo, evals, and tests set IMPECCABLE_CATALOG_DIR), then the roll
|
||||
* API at impeccable.style, then a degraded assignment-only seed when both are
|
||||
* unavailable. --chosen sends the anonymous choice ping for API-dealt rolls;
|
||||
* DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables it.
|
||||
* unavailable. The anonymous choice ping fires once per resolved attended
|
||||
* round on API-dealt rolls: --kind names which card class won (assigned,
|
||||
* pick, challenger, canon) so share metrics have a denominator, --chosen
|
||||
* carries the catalog id when a dealt challenger won, and --register rides
|
||||
* along when the round came from a steered hand. Grounded candidates' names
|
||||
* never leave the machine. DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables
|
||||
* the ping entirely.
|
||||
*
|
||||
* Env vars:
|
||||
* IMPECCABLE_CONCEPT_SEED — same as --from; for reproducible eval runs.
|
||||
@@ -172,17 +189,35 @@ function telemetryDisabled() {
|
||||
return Boolean(process.env.IMPECCABLE_NO_TELEMETRY || process.env.DO_NOT_TRACK);
|
||||
}
|
||||
|
||||
// Anonymous choice ping: records only that a dealt world was selected.
|
||||
// Anonymous choice ping: one per resolved attended direction round. kind
|
||||
// says which card class won (assigned / pick / challenger / canon), so
|
||||
// pick-share and canon-share have a denominator; chosenId rides along only
|
||||
// when a dealt catalog world won, and register only when the round came from
|
||||
// a steered hand. Grounded candidates' names never leave the machine: they
|
||||
// are derived from the user's project, so the ping carries the kind alone.
|
||||
// Fire-and-forget; never fails the caller.
|
||||
export async function pingChosen({ chosenId, key, scope, mode }) {
|
||||
if (telemetryDisabled() || !chosenId) return false;
|
||||
const PING_KINDS = new Set(['assigned', 'pick', 'challenger', 'canon']);
|
||||
export async function pingChosen({ chosenId, key, scope, mode, kind, register }) {
|
||||
if (telemetryDisabled()) return false;
|
||||
if (kind && !PING_KINDS.has(kind)) return false;
|
||||
if (register && register !== 'safer' && register !== 'bolder') return false;
|
||||
// Legacy shape: a bare challenger id with no kind stays a valid ping.
|
||||
if (!chosenId && !kind) return false;
|
||||
if ((kind === 'challenger' || !kind) && !chosenId) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
await fetch(`${API_BASE}/chosen`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chosenId, key, scope, mode }),
|
||||
body: JSON.stringify({
|
||||
...(chosenId ? { chosenId } : {}),
|
||||
key,
|
||||
scope,
|
||||
mode,
|
||||
...(kind ? { kind } : {}),
|
||||
...(register ? { register } : {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
return true;
|
||||
@@ -260,6 +295,7 @@ export function renderConceptSeed({
|
||||
scope = 'surface',
|
||||
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
|
||||
reroll = 0,
|
||||
register = null,
|
||||
mode = null,
|
||||
grain = null,
|
||||
platform = null,
|
||||
@@ -273,6 +309,15 @@ export function renderConceptSeed({
|
||||
if (!Number.isInteger(reroll) || reroll < 0) {
|
||||
throw new Error('concept-seed: --reroll must be a non-negative integer');
|
||||
}
|
||||
if (register !== null && register !== 'safer' && register !== 'bolder') {
|
||||
throw new Error('concept-seed: --register must be safer or bolder');
|
||||
}
|
||||
if (register !== null && reroll < 1) {
|
||||
throw new Error('concept-seed: --register steers a re-roll round; pass --reroll <n> with it');
|
||||
}
|
||||
if (register !== null && scope !== 'direction') {
|
||||
throw new Error('concept-seed: --register applies to direction rounds only');
|
||||
}
|
||||
if (mode !== null && !SEED_MODES.has(mode)) {
|
||||
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
|
||||
}
|
||||
@@ -326,6 +371,7 @@ export function renderConceptSeed({
|
||||
scope,
|
||||
key,
|
||||
reroll,
|
||||
register,
|
||||
mode,
|
||||
grain,
|
||||
platform,
|
||||
@@ -357,7 +403,11 @@ export function renderConceptSeed({
|
||||
survive the current task plus navigation, quiet and dense content,
|
||||
interaction and state, and a substantially different future surface. In an
|
||||
attended run, present the assigned direction fully committed and offer
|
||||
re-roll; never present a ranked lineup to choose from. Re-roll yourself only
|
||||
re-roll. You may add ONE card for your top-ranked grounded candidate when
|
||||
it is not the assigned direction, kicker MY PICK, with an honest risk line
|
||||
naming its familiarity; one pick card, never a ranked lineup, and the pick
|
||||
never takes the lead position. When the assignment IS your top candidate,
|
||||
there is no pick card. Re-roll yourself only
|
||||
on named factual grounds, when the assignment cannot carry the product's
|
||||
truth or task; taste is never grounds.`
|
||||
: `After ordering the task's grounded structural candidates by resonance,
|
||||
@@ -374,7 +424,16 @@ export function renderConceptSeed({
|
||||
conflicts. Weigh the fused result 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. A fused challenger that wins both axes becomes the build.`
|
||||
list is the point. A fused challenger that wins both axes becomes the build.
|
||||
Close the weighing with a verdict per challenger, decided before any
|
||||
borrowing is considered: wins (beats the assigned direction on both axes),
|
||||
competitive (holds one axis), 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, never
|
||||
the challenger's clothes; one world owns the page. Write each raise as its
|
||||
own named line on the presented direction, and carry every verdict, kept
|
||||
line, and raise into the decision page payload.`
|
||||
: `A challenger wins only when its fused result beats the grounded list on
|
||||
audience identification and product clarity. It may change task topology or
|
||||
interaction, but never the committed visual identity.`;
|
||||
@@ -399,19 +458,55 @@ Ambitious motion, spatial media, or interaction is welcome when it strengthens
|
||||
the product without weakening semantics, performance, or fallback behavior.`;
|
||||
|
||||
if (!data) {
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount})
|
||||
ASSIGNED INDEX: ${buildIndex}
|
||||
// A degraded roll can still serve the safer register, which needs no
|
||||
// catalog at all: the assignment machinery is suppressed entirely, the
|
||||
// same as the non-degraded safer round, because emitting both "the user
|
||||
// picks" and a mandatory numbered build order hands the model two
|
||||
// contradicting instructions and the mandatory one tends to win. The
|
||||
// bolder register is exactly the thing degradation took away, so it
|
||||
// falls back to a plain grounded round, disclosed.
|
||||
const degradedHeader = `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount})`;
|
||||
if (register === 'safer') {
|
||||
return `${degradedHeader}
|
||||
SAFER REGISTER (user-requested): the assigned index is suspended this
|
||||
round; the user picks, and no candidate is mandated. Present the familiar
|
||||
register: your remaining grounded candidates from the conventional end, at
|
||||
most three, as full cards with an honest risk line each, plus the canon
|
||||
executed against two or three named competitors. This is the one sanctioned
|
||||
lineup of your own ranked candidates; it exists only by this explicit
|
||||
request. When the user voices a standing preference for it, record a brand
|
||||
commitment in PRODUCT.md.
|
||||
${authorityInstruction}
|
||||
A user- or brief-pinned decision beats the roll, always.
|
||||
REGISTER (restated for truncated readers): safer, user-requested; the
|
||||
assigned index is suspended this round and the user picks; seed key ${key}.
|
||||
`;
|
||||
}
|
||||
const degradedRegister = register === 'bolder'
|
||||
? `BOLDER REGISTER UNAVAILABLE: bolder deals foreign forms, and this roll ran
|
||||
degraded with no catalog and no roll service, so there is nothing bold to
|
||||
deal. Tell the user, then run this round as a plain grounded re-roll; the
|
||||
assignment below applies.
|
||||
`
|
||||
: '';
|
||||
return `${degradedHeader}
|
||||
${degradedRegister}ASSIGNED INDEX: ${buildIndex}
|
||||
${promotedInstruction}
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
No challengers this run: the roll service was unreachable and no local
|
||||
catalog exists. A sandboxed exec tool with no network access causes exactly
|
||||
this; before accepting degradation, rerun this command once through the
|
||||
harness's network-enabled command tool. A sandboxed shell without network egress is the most common
|
||||
cause: if this harness can rerun the command with network access granted,
|
||||
do that once before proceeding. Otherwise proceed with the grounded
|
||||
candidates alone; the assignment
|
||||
above still applies at full strength. Tell the user plainly that this roll
|
||||
catalog exists. A sandboxed shell without network egress is the most common
|
||||
cause; before accepting degradation, rerun this command once through the
|
||||
harness's network-enabled or escalated command tool. When that rerun needs
|
||||
an approval, state exactly what the approver must know: this script's only
|
||||
network contact is one GET to https://impeccable.style/api/roll whose query
|
||||
carries scope, mode, an eight-hex seed key, and a re-roll counter; no
|
||||
project files, prompts, code, or conversation context are transmitted, and
|
||||
nothing is written. An approval request naming that URL and payload judges
|
||||
the real action; a bare "run with network" invites rejection for contacting
|
||||
an unspecified domain. If the rerun is still refused, proceed with the
|
||||
grounded candidates alone; the assignment above still applies at full
|
||||
strength. Tell the user plainly that this roll
|
||||
ran degraded, with no challengers and no quality-bar boards; do not present
|
||||
the outcome as a full roll. A degraded roll changes the cards, not the
|
||||
channel: when a browser can open, present the direction on the decision page
|
||||
@@ -466,34 +561,76 @@ structure only, never a palette, typeface, or material. Treat them as serious
|
||||
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
|
||||
: '';
|
||||
const rerollBlock = reroll > 0
|
||||
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
|
||||
and challenger alike, is eliminated and may not return reworded. Derive
|
||||
? `RE-ROLL ROUND ${reroll}${register ? ` (${register.toUpperCase()} REGISTER, user-requested)` : ''}: every candidate presented in earlier rounds, grounded
|
||||
and challenger alike, is eliminated and may not return reworded.${register ? '' : ` Derive
|
||||
genuinely new grounded candidates from unexplored angles before judging
|
||||
these fresh challengers.\n`
|
||||
these fresh challengers.`}\n`
|
||||
: '';
|
||||
// A register swaps the round's presentation, never its deal: the assigned
|
||||
// index and challenger fetch stay identical so the chain reproduces, and
|
||||
// only the instructions change.
|
||||
const saferBlock = `SAFER REGISTER: the user asked for the familiar end of the spectrum, so this
|
||||
round's dealt hand is spent unseen, stays excluded from future rounds, and
|
||||
is not printed. The assigned index is suspended this round; the user picks. Present the familiar register: your remaining grounded
|
||||
candidates from the conventional end, at most three, as full cards with an
|
||||
honest risk line each, plus the canon executed against two or three named
|
||||
competitors. This is the one sanctioned lineup of your own ranked
|
||||
candidates; it exists only by this explicit request. When the user voices a
|
||||
standing preference for it, record a brand commitment in PRODUCT.md.`;
|
||||
const bolderBlock = `BOLDER REGISTER: the user asked for foreign forms at full commitment, so no
|
||||
grounded direction is presented this round and the assigned index is
|
||||
suspended. The hand is every dealt challenger below, each fused with the
|
||||
product and presented as a full card; the FIRST dealt challenger leads, an
|
||||
assignment by deal order, so the dice still choose. Verdicts and donations
|
||||
apply between the challengers, weighed against the leader. The pick card
|
||||
sits out; the canon stays, as always.`;
|
||||
const telemetryBlock = data.source === 'api'
|
||||
? `TELEMETRY: if the resolved direction uses one of these challengers, rerun
|
||||
this script once with --chosen <challenger-id> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''}
|
||||
after resolution. The ping is anonymous (chosen id only) and is skipped
|
||||
automatically when DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY is set.\n`
|
||||
? `TELEMETRY: after the user's choice resolves, rerun this script once with
|
||||
--kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''},
|
||||
adding --chosen <challenger-id> when a dealt challenger won and keeping
|
||||
--register <safer|bolder> when the resolved round came from a steered hand.
|
||||
One ping per resolved attended round. The ping is anonymous, the card kind
|
||||
plus the catalog id when one won; your grounded candidates' names never
|
||||
leave the machine, and the ping is skipped automatically when DO_NOT_TRACK
|
||||
or IMPECCABLE_NO_TELEMETRY is set.\n`
|
||||
: '';
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
|
||||
${rerollBlock}ASSIGNED INDEX: ${buildIndex}
|
||||
const assignedBlock = register === null
|
||||
? `ASSIGNED INDEX: ${buildIndex}
|
||||
${promotedInstruction}
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
CHALLENGERS:
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.`
|
||||
: register === 'safer' ? saferBlock : bolderBlock;
|
||||
// A bolder round has no assigned grounded direction, so the generic
|
||||
// weighing instruction (which measures against the assignment) would
|
||||
// contradict the register; the bolder variant weighs against the leader.
|
||||
const bolderChallengerInstruction = `Fuse each challenger before judging it: the challenger supplies the form
|
||||
and its system grammar, the product supplies every fact, and clarity wins
|
||||
conflicts. Weigh every fused challenger against the fused LEADER, the first
|
||||
dealt, on exactly two axes, audience identification and product clarity;
|
||||
verdicts and donations apply between the challengers, and one that beats
|
||||
the leader on both axes presents as the hand's strongest alternate.`;
|
||||
const roundChallengerInstruction = register === 'bolder' ? bolderChallengerInstruction : challengerInstruction;
|
||||
const challengerSection = register === 'safer'
|
||||
? ''
|
||||
: `CHALLENGERS:
|
||||
${data.challengers.map(renderChallenger).join('\n')}
|
||||
${compositionBlock}${challengerInstruction}
|
||||
${compositionBlock}${roundChallengerInstruction}
|
||||
When you can view images, open the QUALITY BAR board and hero for any
|
||||
challenger you weigh seriously and for the world you build. They exist as a
|
||||
craft bar, the finish level and commitment the build is expected to reach,
|
||||
never as a mockup to copy; your surface serves this product, not that render.
|
||||
${authorityInstruction}
|
||||
`;
|
||||
const restated = register === null
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||
: `REGISTER (restated for truncated readers): ${register}, user-requested; the
|
||||
assigned index is suspended this round; seed key ${key}.`;
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
|
||||
${rerollBlock}${assignedBlock}
|
||||
${challengerSection}${authorityInstruction}
|
||||
${richnessInstruction}
|
||||
${telemetryBlock}A user- or brief-pinned decision beats the roll, always.
|
||||
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.
|
||||
${restated}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -502,19 +639,25 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
const fromIdx = args.indexOf('--from');
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
const rerollIdx = args.indexOf('--reroll');
|
||||
const registerIdx = args.indexOf('--register');
|
||||
const modeIdx = args.indexOf('--mode');
|
||||
const grainIdx = args.indexOf('--grain');
|
||||
const platformIdx = args.indexOf('--platform');
|
||||
const candidateCountIdx = args.indexOf('--candidate-count');
|
||||
const chosenIdx = args.indexOf('--chosen');
|
||||
const kindIdx = args.indexOf('--kind');
|
||||
try {
|
||||
if (chosenIdx !== -1) {
|
||||
if (chosenIdx !== -1 || kindIdx !== -1) {
|
||||
// Choice ping: always exits 0, telemetry must never fail a design flow.
|
||||
// --kind alone pings a non-challenger outcome (assigned/pick/canon);
|
||||
// --chosen alone stays the legacy challenger-win ping.
|
||||
const sent = await pingChosen({
|
||||
chosenId: args[chosenIdx + 1],
|
||||
chosenId: chosenIdx !== -1 ? args[chosenIdx + 1] : undefined,
|
||||
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
|
||||
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
|
||||
mode: modeIdx !== -1 ? args[modeIdx + 1] : undefined,
|
||||
kind: kindIdx !== -1 ? args[kindIdx + 1] : undefined,
|
||||
register: registerIdx !== -1 ? args[registerIdx + 1] : undefined,
|
||||
});
|
||||
process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n');
|
||||
} else {
|
||||
@@ -537,6 +680,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
? args[fromIdx + 1]
|
||||
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
|
||||
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
|
||||
register: registerIdx !== -1 ? args[registerIdx + 1] : null,
|
||||
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
|
||||
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
|
||||
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
|
||||
@@ -548,6 +692,13 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
process.exitCode = 1;
|
||||
}
|
||||
// A raced-out fetch may still hold a socket; exit explicitly so the CLI
|
||||
// never lingers on a dead network path after output is written.
|
||||
// never lingers on a dead network path after output is written. Destroy
|
||||
// fetch's global undici dispatcher first: process.exit() with a live
|
||||
// keep-alive socket trips a libuv assertion on Windows and aborts the
|
||||
// process after a successful roll (nodejs/node#56645).
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { loadContext, extractPlatform } from './context.mjs';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
import { readLatestSnapshotAcrossTargets } from './critique-storage.mjs';
|
||||
|
||||
/** Is there code here at all, or just context files / an empty repo? */
|
||||
function hasCode(cwd) {
|
||||
@@ -34,23 +34,13 @@ function hasCode(cwd) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent critique snapshot across all targets. Filenames are
|
||||
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
|
||||
* Parses the small frontmatter for score + P0/P1 counts.
|
||||
* Summarize the most recent critique snapshot across all targets.
|
||||
*/
|
||||
function latestCritique(cwd) {
|
||||
try {
|
||||
const dir = getCritiqueDir(cwd);
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
|
||||
if (!files.length) return null;
|
||||
const newest = files[files.length - 1];
|
||||
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
|
||||
const front = text.split('---')[1] || '';
|
||||
const get = (k) => {
|
||||
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
|
||||
return m ? m[1].trim() : null;
|
||||
};
|
||||
const latest = readLatestSnapshotAcrossTargets({ cwd });
|
||||
if (!latest) return null;
|
||||
const get = (key) => latest.meta[key] ?? null;
|
||||
const num = (v) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
@@ -61,7 +51,7 @@ function latestCritique(cwd) {
|
||||
p0: num(get('p0')),
|
||||
p1: num(get('p1')),
|
||||
timestamp: get('timestamp'),
|
||||
file: path.relative(cwd, path.join(dir, newest)),
|
||||
file: path.relative(cwd, latest.path),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
* shape rather than the markdown block.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -1146,6 +1147,7 @@ async function cli() {
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
@@ -1180,6 +1182,7 @@ async function cli() {
|
||||
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
|
||||
);
|
||||
}
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (!ctx.platform) {
|
||||
// A `## Platform` section that names something we don't recognize (a
|
||||
@@ -1275,9 +1278,10 @@ function appendImageGenDirective(parts) {
|
||||
if (!process.env.OPENAI_API_KEY) return;
|
||||
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
|
||||
parts.push([
|
||||
'IMAGE_GEN_AVAILABLE: An OpenAI key is present, so image generation works even without a harness-native image tool:',
|
||||
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render).`,
|
||||
'Prefer the harness-native image tool when one exists. Visualizing a direction before building it measurably strengthens the result.',
|
||||
'IMAGE_GEN_AVAILABLE: your harness-native image tool is always the first choice for generation; use it whenever one exists.',
|
||||
'This environment also carries an OpenAI key as the fallback for harnesses with no native tool:',
|
||||
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render, and never reach for it when a native tool exists).`,
|
||||
'Visualizing a direction before building it measurably strengthens the result.',
|
||||
].join(' '));
|
||||
}
|
||||
|
||||
@@ -1332,6 +1336,19 @@ function appendDetectorFallback(parts, ctx) {
|
||||
// markdown already in memory, a bounded set of stats, or one of the small JSON
|
||||
// files the boot reads regardless. The deep pass (git drift, token divergence,
|
||||
// cross-workspace sweep) belongs to the doctor command, not to every session.
|
||||
// One boot-time probe replaces every session re-deriving its image toolchain:
|
||||
// harnesses and OSes differ (cwebp, sips on macOS, magick, ffmpeg), and the
|
||||
// agent should read this line instead of running command -v per image.
|
||||
function appendImageToolsDirective(parts) {
|
||||
const probe = process.platform === 'win32' ? 'where' : 'which';
|
||||
const found = ['cwebp', 'sips', 'magick', 'ffmpeg'].filter((tool) => {
|
||||
try { return spawnSync(probe, [tool], { stdio: 'ignore' }).status === 0; } catch { return false; }
|
||||
});
|
||||
parts.push(found.length
|
||||
? `IMAGE_TOOLS: available image converters on this machine: ${found.join(', ')}. Use the first suitable one; never probe again this session.`
|
||||
: 'IMAGE_TOOLS: no image converter found (cwebp, sips, magick, ffmpeg). Ship PNG output unconverted rather than probing per image.');
|
||||
}
|
||||
|
||||
function appendStalenessDirective(parts, ctx, options) {
|
||||
const projectRoot = ctx.projectRoot || process.cwd();
|
||||
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
|
||||
|
||||
@@ -105,28 +105,37 @@ function parseFrontmatter(text) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all snapshot files for `slug`, sorted oldest → newest.
|
||||
* Return snapshot files matching `suffix`, sorted oldest → newest.
|
||||
*/
|
||||
function listSnapshotsForSlug(slug, cwd) {
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
|
||||
|
||||
function listSnapshots(suffix, cwd) {
|
||||
const dir = getCritiqueDir(cwd);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const suffix = `__${slug}.md`;
|
||||
return fs.readdirSync(dir)
|
||||
.filter((f) => f.endsWith(suffix))
|
||||
.filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
|
||||
.sort()
|
||||
.map((f) => path.join(dir, f));
|
||||
}
|
||||
|
||||
function readLatestSnapshotMatching(suffix, cwd) {
|
||||
const filePath = listSnapshots(suffix, cwd).at(-1);
|
||||
if (!filePath) return null;
|
||||
const body = fs.readFileSync(filePath, 'utf-8');
|
||||
return { path: filePath, body, meta: parseFrontmatter(body) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
||||
* to find its fix backlog when the slug matches.
|
||||
*/
|
||||
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
const all = listSnapshotsForSlug(slug, cwd);
|
||||
if (!all.length) return null;
|
||||
const latest = all[all.length - 1];
|
||||
const body = fs.readFileSync(latest, 'utf-8');
|
||||
return { path: latest, body, meta: parseFrontmatter(body) };
|
||||
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
|
||||
}
|
||||
|
||||
/** Return the most recent snapshot across all targets, or null. */
|
||||
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching('.md', cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +143,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
* Critique appends a one-line trend to its output using this.
|
||||
*/
|
||||
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
||||
const all = listSnapshotsForSlug(slug, cwd);
|
||||
const all = listSnapshots(`__${slug}.md`, cwd);
|
||||
const slice = all.slice(-limit);
|
||||
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
|
||||
}
|
||||
|
||||
@@ -683,6 +683,10 @@ if (IS_BROWSER) {
|
||||
|
||||
const reasons = collectVisualContrastReasons(el, style);
|
||||
if (reasons.length === 0) continue;
|
||||
// Image-only mode filters here, inside the cap: gradient/opacity/filter
|
||||
// candidates earlier in DOM order must not consume the budget and
|
||||
// starve the url()-backed texts this mode exists to sample.
|
||||
if (options.imageOnly && !reasons.includes('image background')) continue;
|
||||
|
||||
const textColor = parseRgb(style.color);
|
||||
const fontSize = parseFloat(style.fontSize) || 16;
|
||||
@@ -1175,6 +1179,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
async function analyzeVisualContrast(options = {}) {
|
||||
// imageOnly is enforced inside the collector, before the candidate cap.
|
||||
const candidates = collectVisualContrastCandidates(options);
|
||||
const results = [];
|
||||
const shouldScrollOffscreen = options.scrollOffscreen === true;
|
||||
@@ -1260,9 +1265,16 @@ if (IS_BROWSER) {
|
||||
|
||||
function addBrowserFindings(groupMap, el, findings) {
|
||||
if (!findings || findings.length === 0) return;
|
||||
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
||||
// matching findings for its whole subtree. Applied at this choke point so
|
||||
// every per-element attribution (checks, layout, occlusion, rhythm)
|
||||
// honors it; page-level findings attributed to <body> pass through
|
||||
// untouched, since body has no ignoring ancestor.
|
||||
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
|
||||
if (kept.length === 0) return;
|
||||
const existing = groupMap.get(el);
|
||||
if (existing) existing.push(...findings);
|
||||
else groupMap.set(el, [...findings]);
|
||||
if (existing) existing.push(...kept);
|
||||
else groupMap.set(el, [...kept]);
|
||||
}
|
||||
|
||||
function browserFindingsFromMap(groupMap) {
|
||||
@@ -1620,9 +1632,27 @@ if (IS_BROWSER) {
|
||||
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
|
||||
node.remove();
|
||||
}
|
||||
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
|
||||
if (htmlPatternFindings.length > 0) {
|
||||
const mapped = htmlPatternFindings.map(f => {
|
||||
// Regex findings that name a live selector resolve against the real DOM:
|
||||
// pseudo-element/class segments are stripped (the host element is the
|
||||
// anchor), a selector that matches nothing on this page drops the finding
|
||||
// (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 => {
|
||||
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;
|
||||
}
|
||||
if (matches.length === 0) return false;
|
||||
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
|
||||
});
|
||||
if (scopedHtmlFindings.length > 0) {
|
||||
const mapped = scopedHtmlFindings.map(f => {
|
||||
const item = { type: f.id, detail: f.snippet };
|
||||
if (f.severity) {
|
||||
item.severity = f.severity;
|
||||
@@ -1652,8 +1682,27 @@ if (IS_BROWSER) {
|
||||
};
|
||||
}
|
||||
|
||||
// Visual contrast has three modes. Explicit true runs the full sampled
|
||||
// pass; explicit false disables it entirely (the deterministic-only mode
|
||||
// the test suites use). Unset — the default overlay run — samples ONLY
|
||||
// image-backed text: the one class the analytic walk deliberately skips,
|
||||
// because a url() layer's pixels are unknowable without looking. In-page
|
||||
// sampling draws the source image alone to a canvas (glyph ink never
|
||||
// pollutes it), and a cross-origin image without CORS reports unresolved
|
||||
// instead of guessing.
|
||||
function visualContrastMode(options = {}) {
|
||||
const explicit = typeof options.visualContrast === 'boolean'
|
||||
? options.visualContrast
|
||||
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
|
||||
? window.__IMPECCABLE_CONFIG__.visualContrast
|
||||
: null;
|
||||
if (explicit === true) return 'full';
|
||||
if (explicit === false) return false;
|
||||
return 'image-only';
|
||||
}
|
||||
|
||||
function shouldRunVisualContrast(options = {}) {
|
||||
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
|
||||
return visualContrastMode(options) !== false;
|
||||
}
|
||||
|
||||
function visualContrastOptions(options = {}) {
|
||||
@@ -1830,6 +1879,7 @@ if (IS_BROWSER) {
|
||||
return [];
|
||||
}
|
||||
const resolvedOptions = visualContrastOptions(options);
|
||||
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
|
||||
const analyses = await analyzeVisualContrast(resolvedOptions);
|
||||
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
|
||||
lastVisualContrastAnalyses = analyses;
|
||||
|
||||
@@ -142,10 +142,73 @@ function stripInlineYamlComment(s) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// YAML double-quoted scalars process backslash escapes. Stripping the outer
|
||||
// quotes without unescaping leaves them in place, so a nested font family like
|
||||
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
|
||||
// reaches allowedFonts as '\"ibm plex sans' and never matches the same family
|
||||
// declared in CSS. Scanner instead of a regex: the escape set is small and the
|
||||
// backslash handling stays readable.
|
||||
// The full YAML 1.2 double-quote escape set (spec section 5.7).
|
||||
const YAML_SIMPLE_ESCAPES = {
|
||||
'0': '\0',
|
||||
a: '\x07',
|
||||
b: '\b',
|
||||
t: '\t',
|
||||
n: '\n',
|
||||
v: '\v',
|
||||
f: '\f',
|
||||
r: '\r',
|
||||
e: '\x1b',
|
||||
' ': ' ',
|
||||
'"': '"',
|
||||
'/': '/',
|
||||
'\\': '\\',
|
||||
N: '\u0085',
|
||||
_: '\u00a0',
|
||||
L: '\u2028',
|
||||
P: '\u2029',
|
||||
};
|
||||
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
|
||||
|
||||
function unescapeYamlDoubleQuoted(body) {
|
||||
let out = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch !== '\\' || i === body.length - 1) {
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
const next = body[i + 1];
|
||||
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
|
||||
out += YAML_SIMPLE_ESCAPES[next];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
|
||||
// literal rather than corrupting the rest of the scalar.
|
||||
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
|
||||
if (hexLen) {
|
||||
const hex = body.slice(i + 2, i + 2 + hexLen);
|
||||
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
|
||||
if (codePoint >= 0 && codePoint <= 0x10ffff) {
|
||||
out += String.fromCodePoint(codePoint);
|
||||
i += 1 + hexLen;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseScalar(raw) {
|
||||
const s = raw.trim();
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||
return unescapeYamlDoubleQuoted(s.slice(1, -1));
|
||||
}
|
||||
// Single-quoted YAML escapes only the quote itself, by doubling it.
|
||||
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
|
||||
return s.slice(1, -1).split("''").join("'");
|
||||
}
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.m
|
||||
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
||||
export {
|
||||
walkDir,
|
||||
hasScannableExtension,
|
||||
SCANNABLE_EXTENSIONS,
|
||||
SKIP_DIRS,
|
||||
buildImportGraph,
|
||||
|
||||
@@ -41,6 +41,221 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||
}
|
||||
|
||||
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
||||
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
|
||||
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
|
||||
|
||||
function isInsideOpeningJsxTag(source) {
|
||||
const tagStart = source.lastIndexOf('<');
|
||||
if (tagStart === -1 || !/^<[A-Za-z][\w.:-]*/.test(source.slice(tagStart))) return false;
|
||||
|
||||
let quote = '';
|
||||
for (let cursor = tagStart + 1; cursor < source.length; cursor++) {
|
||||
const char = source[cursor];
|
||||
if (quote) {
|
||||
if (char === '\\') cursor++;
|
||||
else if (char === quote) quote = '';
|
||||
} else if (char === "'" || char === '"') {
|
||||
quote = char;
|
||||
} else if (char === '>') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank JavaScript comments without moving any following source. Regex
|
||||
* findings keep their original line numbers, while prose examples inside
|
||||
* comments cannot masquerade as rendered markup.
|
||||
*/
|
||||
function stripJsComments(content, options = {}) {
|
||||
let state = 'code';
|
||||
let output = '';
|
||||
let lastSignificant = '';
|
||||
let previousSignificant = '';
|
||||
let antePreviousSignificant = '';
|
||||
let currentWord = '';
|
||||
let currentWordPrefix = '';
|
||||
let wordSeparated = false;
|
||||
let regexCharClass = false;
|
||||
let jsxExpressionDepth = 0;
|
||||
let lastClosedBraceKind = '';
|
||||
const braceKinds = [];
|
||||
const templateExpressionDepths = [];
|
||||
|
||||
const braceKind = (startsJsxExpression = false) => (
|
||||
!startsJsxExpression && (
|
||||
!lastSignificant ||
|
||||
lastSignificant === ')' ||
|
||||
lastSignificant === ';' ||
|
||||
lastSignificant === '}' ||
|
||||
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
||||
) ? 'block' : 'expression'
|
||||
);
|
||||
|
||||
const recordSignificant = (char) => {
|
||||
if (/\s/.test(char)) {
|
||||
wordSeparated = true;
|
||||
return;
|
||||
}
|
||||
const isWordChar = /[\w$]/.test(char);
|
||||
if (isWordChar && (wordSeparated || !currentWord)) {
|
||||
currentWord = '';
|
||||
currentWordPrefix = lastSignificant;
|
||||
} else if (!isWordChar) {
|
||||
currentWordPrefix = '';
|
||||
}
|
||||
wordSeparated = false;
|
||||
antePreviousSignificant = previousSignificant;
|
||||
previousSignificant = lastSignificant;
|
||||
lastSignificant = char;
|
||||
currentWord = isWordChar ? currentWord + char : '';
|
||||
};
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const char = content[i];
|
||||
const next = content[i + 1];
|
||||
|
||||
if (state === 'line-comment') {
|
||||
if (char === '\n') {
|
||||
output += char;
|
||||
state = 'code';
|
||||
} else {
|
||||
output += ' ';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state === 'block-comment') {
|
||||
if (char === '*' && next === '/') {
|
||||
output += ' ';
|
||||
i++;
|
||||
state = 'code';
|
||||
} else {
|
||||
output += char === '\n' ? '\n' : ' ';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state === 'regex') {
|
||||
output += char;
|
||||
if (char === '\\' && next) {
|
||||
output += next;
|
||||
i++;
|
||||
} else if (char === '[') {
|
||||
regexCharClass = true;
|
||||
} else if (char === ']') {
|
||||
regexCharClass = false;
|
||||
} else if (char === '/' && !regexCharClass) {
|
||||
state = 'code';
|
||||
recordSignificant('/');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state === 'template' && char === '$' && next === '{') {
|
||||
output += '${';
|
||||
i++;
|
||||
recordSignificant('$');
|
||||
recordSignificant('{');
|
||||
templateExpressionDepths.push(1);
|
||||
braceKinds.push('expression');
|
||||
if (jsxExpressionDepth) jsxExpressionDepth++;
|
||||
state = 'code';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state !== 'code') {
|
||||
output += char;
|
||||
if (char === '\\' && next) {
|
||||
output += next;
|
||||
i++;
|
||||
} else if (
|
||||
(state === 'single-quote' && char === "'") ||
|
||||
(state === 'double-quote' && char === '"') ||
|
||||
(state === 'template' && char === '`')
|
||||
) {
|
||||
state = 'code';
|
||||
recordSignificant(char);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const jsxUrlSeparator = options.jsx && char === '/' && next === '/' &&
|
||||
jsxExpressionDepth === 0 &&
|
||||
(output.endsWith('http:') ||
|
||||
output.endsWith('https:') ||
|
||||
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1)) &&
|
||||
/^[\w.-]+\.[A-Za-z]{2,}(?=[:/?#\s<]|$)/.test(content.slice(i + 2))));
|
||||
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
||||
previousSignificant === lastSignificant &&
|
||||
antePreviousSignificant !== lastSignificant;
|
||||
if (char === '/' && next === '/' && jsxUrlSeparator) {
|
||||
output += '//';
|
||||
i++;
|
||||
recordSignificant('/');
|
||||
recordSignificant('/');
|
||||
} else if (char === '/' && next === '/') {
|
||||
output += ' ';
|
||||
i++;
|
||||
state = 'line-comment';
|
||||
} else if (char === '/' && next === '*') {
|
||||
output += ' ';
|
||||
i++;
|
||||
state = 'block-comment';
|
||||
} else if (templateExpressionDepths.length && char === '{') {
|
||||
output += char;
|
||||
templateExpressionDepths[templateExpressionDepths.length - 1]++;
|
||||
braceKinds.push(braceKind());
|
||||
if (jsxExpressionDepth) jsxExpressionDepth++;
|
||||
recordSignificant(char);
|
||||
} else if (templateExpressionDepths.length && char === '}') {
|
||||
output += char;
|
||||
const depthIndex = templateExpressionDepths.length - 1;
|
||||
templateExpressionDepths[depthIndex]--;
|
||||
lastClosedBraceKind = braceKinds.pop() || '';
|
||||
if (jsxExpressionDepth) jsxExpressionDepth--;
|
||||
recordSignificant(char);
|
||||
if (templateExpressionDepths[depthIndex] === 0) {
|
||||
templateExpressionDepths.pop();
|
||||
state = 'template';
|
||||
}
|
||||
} else if (
|
||||
char === '/' &&
|
||||
(!lastSignificant ||
|
||||
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
||||
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
||||
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
||||
) {
|
||||
output += char;
|
||||
state = 'regex';
|
||||
regexCharClass = false;
|
||||
} else {
|
||||
output += char;
|
||||
const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 &&
|
||||
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1, -1)) ||
|
||||
isInsideOpeningJsxTag(output.slice(0, -1)));
|
||||
if (char === '{') braceKinds.push(braceKind(startsJsxExpression));
|
||||
else if (char === '}') lastClosedBraceKind = braceKinds.pop() || '';
|
||||
if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth++;
|
||||
else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth--;
|
||||
recordSignificant(char);
|
||||
if (char === "'") state = 'single-quote';
|
||||
else if (char === '"') state = 'double-quote';
|
||||
else if (char === '`') state = 'template';
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function stripCssComments(content) {
|
||||
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
|
||||
}
|
||||
|
||||
function firstOverusedGoogleFont(text) {
|
||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||
}
|
||||
@@ -528,18 +743,198 @@ function extractStyleBlocks(content, ext) {
|
||||
|
||||
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
||||
|
||||
function findQuotedStringEnd(content, start, quote) {
|
||||
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||
if (content[cursor] === '\\') cursor++;
|
||||
else if (content[cursor] === quote) return cursor;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findRegexLiteralEnd(content, start) {
|
||||
let inCharacterClass = false;
|
||||
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||
const char = content[cursor];
|
||||
if (char === '\\') {
|
||||
cursor++;
|
||||
} else if (char === '[') {
|
||||
inCharacterClass = true;
|
||||
} else if (char === ']') {
|
||||
inCharacterClass = false;
|
||||
} else if (char === '/' && !inCharacterClass) {
|
||||
while (/[A-Za-z]/.test(content[cursor + 1] || '')) cursor++;
|
||||
return cursor;
|
||||
} else if (char === '\n' || char === '\r') {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findTemplateExpressionEnd(content, start) {
|
||||
let depth = 1;
|
||||
let lastSignificant = '';
|
||||
let previousSignificant = '';
|
||||
let antePreviousSignificant = '';
|
||||
let currentWord = '';
|
||||
let currentWordPrefix = '';
|
||||
let wordSeparated = false;
|
||||
let lastClosedBraceKind = '';
|
||||
const braceKinds = [];
|
||||
|
||||
const braceKind = () => (
|
||||
lastSignificant === ')' ||
|
||||
lastSignificant === ';' ||
|
||||
lastSignificant === '}' ||
|
||||
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
||||
? 'block'
|
||||
: 'expression'
|
||||
);
|
||||
|
||||
const recordSignificant = (char) => {
|
||||
if (/\s/.test(char)) {
|
||||
wordSeparated = true;
|
||||
return;
|
||||
}
|
||||
const isWordChar = /[\w$]/.test(char);
|
||||
if (isWordChar && (wordSeparated || !currentWord)) {
|
||||
currentWord = '';
|
||||
currentWordPrefix = lastSignificant;
|
||||
} else if (!isWordChar) {
|
||||
currentWordPrefix = '';
|
||||
}
|
||||
wordSeparated = false;
|
||||
antePreviousSignificant = previousSignificant;
|
||||
previousSignificant = lastSignificant;
|
||||
lastSignificant = char;
|
||||
currentWord = isWordChar ? currentWord + char : '';
|
||||
};
|
||||
|
||||
for (let cursor = start; cursor < content.length; cursor++) {
|
||||
const char = content[cursor];
|
||||
const next = content[cursor + 1];
|
||||
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
||||
previousSignificant === lastSignificant &&
|
||||
antePreviousSignificant !== lastSignificant;
|
||||
if (char === "'" || char === '"') {
|
||||
cursor = findQuotedStringEnd(content, cursor, char);
|
||||
if (cursor === -1) return -1;
|
||||
recordSignificant(')');
|
||||
} else if (char === '/' && next === '/') {
|
||||
const lineEnd = content.indexOf('\n', cursor + 2);
|
||||
if (lineEnd === -1) return -1;
|
||||
cursor = lineEnd;
|
||||
} else if (char === '/' && next === '*') {
|
||||
const commentEnd = content.indexOf('*/', cursor + 2);
|
||||
if (commentEnd === -1) return -1;
|
||||
cursor = commentEnd + 1;
|
||||
} else if (
|
||||
char === '/' &&
|
||||
(!lastSignificant ||
|
||||
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
||||
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
||||
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
||||
) {
|
||||
cursor = findRegexLiteralEnd(content, cursor);
|
||||
if (cursor === -1) return -1;
|
||||
recordSignificant(')');
|
||||
} else if (char === '`') {
|
||||
cursor = findTemplateLiteralEnd(content, cursor);
|
||||
if (cursor === -1) return -1;
|
||||
recordSignificant(')');
|
||||
} else if (char === '{') {
|
||||
depth++;
|
||||
braceKinds.push(braceKind());
|
||||
recordSignificant(char);
|
||||
} else if (char === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return cursor;
|
||||
lastClosedBraceKind = braceKinds.pop() || '';
|
||||
recordSignificant(char);
|
||||
} else {
|
||||
recordSignificant(char);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findTemplateLiteralEnd(content, start) {
|
||||
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||
const char = content[cursor];
|
||||
if (char === '\\') {
|
||||
cursor++;
|
||||
} else if (char === '`') {
|
||||
return cursor;
|
||||
} else if (char === '$' && content[cursor + 1] === '{') {
|
||||
cursor = findTemplateExpressionEnd(content, cursor + 2);
|
||||
if (cursor === -1) return -1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findCSSinJSTemplates(content) {
|
||||
const templates = [];
|
||||
const tagRe = /\b(?:styled(?:\.\w+|\([^)]+\))|css)/g;
|
||||
let match;
|
||||
while ((match = tagRe.exec(content)) !== null) {
|
||||
let cursor = match.index + match[0].length;
|
||||
while (/\s/.test(content[cursor] || '')) cursor++;
|
||||
|
||||
if (content[cursor] === '<') {
|
||||
let depth = 0;
|
||||
while (cursor < content.length) {
|
||||
const char = content[cursor];
|
||||
if (char === '<') depth++;
|
||||
else if (char === '>' && content[cursor - 1] !== '=') depth--;
|
||||
cursor++;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
if (depth !== 0) continue;
|
||||
while (/\s/.test(content[cursor] || '')) cursor++;
|
||||
}
|
||||
|
||||
if (content[cursor] !== '`') continue;
|
||||
const contentStart = cursor + 1;
|
||||
cursor = findTemplateLiteralEnd(content, cursor);
|
||||
if (cursor === -1) continue;
|
||||
|
||||
templates.push({
|
||||
tagStart: match.index,
|
||||
contentStart,
|
||||
contentEnd: cursor,
|
||||
});
|
||||
tagRe.lastIndex = cursor + 1;
|
||||
}
|
||||
return templates;
|
||||
}
|
||||
|
||||
function extractCSSinJS(content, ext) {
|
||||
ext = ext.toLowerCase();
|
||||
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
||||
const blocks = [];
|
||||
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const before = content.substring(0, m.index);
|
||||
return findCSSinJSTemplates(content).map((template) => {
|
||||
const before = content.substring(0, template.tagStart);
|
||||
const startLine = before.split('\n').length;
|
||||
blocks.push({ content: m[1], startLine });
|
||||
return {
|
||||
content: content.slice(template.contentStart, template.contentEnd),
|
||||
startLine,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function stripCssInJsComments(content, ext) {
|
||||
if (!CSS_IN_JS_EXTENSIONS.has(ext.toLowerCase())) return content;
|
||||
const templates = findCSSinJSTemplates(content);
|
||||
let output = '';
|
||||
let cursor = 0;
|
||||
for (const template of templates) {
|
||||
output += content.slice(cursor, template.contentStart);
|
||||
output += stripCssComments(content.slice(template.contentStart, template.contentEnd));
|
||||
cursor = template.contentEnd;
|
||||
}
|
||||
return blocks;
|
||||
return output + content.slice(cursor);
|
||||
}
|
||||
|
||||
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
|
||||
@@ -627,8 +1022,12 @@ function runTextContentAnalyzers(content, filePath, options = {}) {
|
||||
function detectText(content, filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
const findings = [];
|
||||
const lines = content.split('\n');
|
||||
const ext = extFromFilePath(filePath);
|
||||
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
|
||||
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
|
||||
}) : content;
|
||||
const source = stripCssInJsComments(commentStrippedSource, ext);
|
||||
const lines = source.split('\n');
|
||||
|
||||
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
||||
// Enable block context for CSS files where related properties span multiple lines
|
||||
@@ -661,8 +1060,8 @@ function detectText(content, filePath, options = {}) {
|
||||
phase: 'source',
|
||||
ruleId: 'codex-grid-background',
|
||||
target: filePath,
|
||||
}, () => scanCssTextForGridBackground(content).map(hit => {
|
||||
const line = content.substring(0, hit.index).split('\n').length;
|
||||
}, () => scanCssTextForGridBackground(source).map(hit => {
|
||||
const line = source.substring(0, hit.index).split('\n').length;
|
||||
return finding('codex-grid-background', filePath, hit.snippet, line);
|
||||
})));
|
||||
|
||||
@@ -698,16 +1097,17 @@ function detectText(content, filePath, options = {}) {
|
||||
phase: 'extract',
|
||||
ruleId: 'css-in-js',
|
||||
target: filePath,
|
||||
}, () => extractCSSinJS(content, ext))
|
||||
: extractCSSinJS(content, ext);
|
||||
}, () => extractCSSinJS(source, ext))
|
||||
: extractCSSinJS(source, ext);
|
||||
for (const block of cssJsBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
const blockContent = stripCssComments(block.content);
|
||||
const blockLines = blockContent.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
profile,
|
||||
phase: 'css-in-js',
|
||||
}));
|
||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
|
||||
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 1));
|
||||
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 1));
|
||||
}
|
||||
|
||||
if (options?.designSystem) {
|
||||
|
||||
@@ -226,6 +226,10 @@ const STATIC_INHERITED_PROPS = new Set([
|
||||
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
|
||||
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
|
||||
'webkitHyphens',
|
||||
// visibility inherits in real CSS, and the invisible-at-rest contrast skip
|
||||
// relies on descendants of a hidden container computing as hidden. A child
|
||||
// that declares `visibility: visible` still overrides the inherited value.
|
||||
'visibility',
|
||||
]);
|
||||
|
||||
const STATIC_DEFAULT_STYLE = {
|
||||
@@ -278,6 +282,7 @@ const STATIC_DEFAULT_STYLE = {
|
||||
marginLeft: '0px',
|
||||
position: 'static',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
@@ -334,6 +339,7 @@ const STATIC_PROP_MAP = {
|
||||
'margin-left': 'marginLeft',
|
||||
'position': 'position',
|
||||
'visibility': 'visibility',
|
||||
'opacity': 'opacity',
|
||||
'top': 'top',
|
||||
'right': 'right',
|
||||
'bottom': 'bottom',
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
checkCreamPalette,
|
||||
checkHtmlPatterns,
|
||||
checkKickerAboveHeadingFromDoc,
|
||||
scopedIgnoreActive,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkPageLayout,
|
||||
checkPageQualityFromDoc,
|
||||
@@ -138,10 +139,21 @@ async function detectHtml(filePath, options = {}) {
|
||||
domutils,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return detectText(html, filePath, options);
|
||||
} catch (err) {
|
||||
if (!globalThis.__impeccableStaticHtmlWarned) {
|
||||
globalThis.__impeccableStaticHtmlWarned = true;
|
||||
|
||||
process.stderr.write(
|
||||
'impeccable detect: DEGRADED - HTML parser modules unavailable ' +
|
||||
'(htmlparser2, css-select, css-tree, domutils).\n' +
|
||||
'Falling back to regex matching. Custom properties, selector matching and computed ' +
|
||||
'contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n'
|
||||
);
|
||||
}
|
||||
|
||||
return detectText(html, filePath, options);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const fileDir = path.dirname(resolvedPath);
|
||||
const root = profileStep(profile, {
|
||||
@@ -171,6 +183,9 @@ async function detectHtml(filePath, options = {}) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = window.getComputedStyle(el);
|
||||
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
|
||||
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
||||
// matching findings for its subtree, same as the browser walk.
|
||||
if (scopedIgnoreActive(el, f.id)) continue;
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
}
|
||||
@@ -238,6 +253,17 @@ async function detectHtml(filePath, options = {}) {
|
||||
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html, patternCorpora).filter(item =>
|
||||
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
|
||||
))) {
|
||||
// Selector-backed page findings honor scoped waivers here too, matching
|
||||
// the browser pass: resolve the selector and drop the finding when an
|
||||
// ignoring ancestor covers a match. Unlike the browser, an unmatched
|
||||
// selector keeps the finding — static scans see partial documents.
|
||||
if (f.selector) {
|
||||
let matches = null;
|
||||
try {
|
||||
matches = document.querySelectorAll(String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim());
|
||||
} catch { matches = null; }
|
||||
if (matches && matches.length > 0 && [...matches].every(el => scopedIgnoreActive(el, f.id))) continue;
|
||||
}
|
||||
const item = finding(f.id, filePath, f.snippet);
|
||||
// Position-aware severity promotion: checks may attach a per-finding
|
||||
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
||||
|
||||
@@ -26,11 +26,26 @@ const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
|
||||
const SCANNABLE_EXTENSIONS = new Set([
|
||||
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
||||
'.jsx', '.tsx', '.js', '.ts',
|
||||
'.vue', '.svelte', '.astro',
|
||||
'.vue', '.svelte', '.astro', '.blade.php',
|
||||
]);
|
||||
|
||||
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
||||
|
||||
function hasScannableExtension(filename) {
|
||||
const lower = filename.toLowerCase();
|
||||
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
|
||||
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
|
||||
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
@@ -40,7 +55,7 @@ function walkDir(dir) {
|
||||
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));
|
||||
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -75,26 +90,11 @@ function buildImportGraph(files) {
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
// ES imports: import ... from '...' and import '...'
|
||||
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
|
||||
let m;
|
||||
while ((m = esRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
|
||||
// CSS @import
|
||||
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
|
||||
while ((m = cssRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
|
||||
// SCSS @use / @forward
|
||||
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
|
||||
while ((m = scssRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
|
||||
for (const match of content.matchAll(pattern)) {
|
||||
const resolved = resolveImport(match[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
graph.set(file, imports);
|
||||
@@ -203,6 +203,7 @@ export {
|
||||
SKIP_DIRS,
|
||||
SCANNABLE_EXTENSIONS,
|
||||
HTML_EXTENSIONS,
|
||||
hasScannableExtension,
|
||||
walkDir,
|
||||
resolveImport,
|
||||
buildImportGraph,
|
||||
|
||||
@@ -11,14 +11,21 @@ import {
|
||||
isBrandFontOnOwnDomain,
|
||||
} from '../shared/constants.mjs';
|
||||
import {
|
||||
CSS_NAMED_COLORS,
|
||||
colorToHex,
|
||||
compositeColorOver,
|
||||
contrastRatio,
|
||||
getHue,
|
||||
hasChroma,
|
||||
isNeutralColor,
|
||||
isNoPaintColorValue,
|
||||
oklchToRgb,
|
||||
parseAnyColor,
|
||||
parseColorMix,
|
||||
parseGradientColors,
|
||||
parseRgb,
|
||||
relativeLuminance,
|
||||
splitTopLevelCommas,
|
||||
} from '../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
|
||||
|
||||
@@ -70,6 +77,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
|
||||
//
|
||||
// An element-scoped waiver that travels with the markup: any element carrying
|
||||
// `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
|
||||
// every rule) suppresses matching findings from itself and its entire subtree,
|
||||
// in every engine that walks elements — the browser overlay, the extension,
|
||||
// and the static scan. This is the DOM twin of the line-based
|
||||
// `impeccable-disable` comment directives, which the browser cannot apply (a
|
||||
// live DOM has no line numbers), and the generalization of the one-off
|
||||
// `data-impeccable-allow-kickers` opt-out.
|
||||
//
|
||||
// The intended use is curated exhibits: a page that documents anti-patterns by
|
||||
// example, or renders a deliberate "before" specimen, marks the container once
|
||||
// and every engine skips it while still scanning the page around it.
|
||||
function scopedIgnoreActive(el, ruleId) {
|
||||
const rule = String(ruleId || '').toLowerCase();
|
||||
let cur = el;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
|
||||
if (attr != null) {
|
||||
const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
|
||||
if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
|
||||
}
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Returns true if the given text is composed entirely of emoji characters
|
||||
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
|
||||
// regardless of CSS `color`, so contrast checks against the element's text
|
||||
@@ -637,6 +672,26 @@ function cssTextHasDarkRootBg(content, customProps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Best-effort extraction of the CSS selector whose declaration block contains
|
||||
// the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
|
||||
// anchor, so the browser pass can resolve scoped ignores against the actual
|
||||
// element and drop patterns that render nowhere on the page. Returns null for
|
||||
// @-rule preludes, keyframe steps, nested blocks, and anything that does not
|
||||
// read as a selector; those findings stay page-level.
|
||||
function enclosingCssSelector(cssText, index) {
|
||||
if (!cssText || !Number.isFinite(index)) return null;
|
||||
const open = cssText.lastIndexOf('{', index);
|
||||
if (open === -1) return null;
|
||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
||||
const raw = cssText.slice(prevClose + 1, open).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`
|
||||
// and `to` would read as (never-matching) type selectors and get a valid
|
||||
// finding wrongly dropped by the zero-match rule downstream.
|
||||
if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function scanCssTextForGlow(content) {
|
||||
const customProps = collectCssCustomProps(content);
|
||||
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
|
||||
@@ -948,6 +1003,7 @@ function scanCssTextForPseudoStripe(rawContent) {
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
||||
index: selectorStart,
|
||||
selector,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
@@ -1010,6 +1066,7 @@ function scanCssTextForInsetStripe(content) {
|
||||
findings.push({
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
|
||||
selector,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -1067,7 +1124,7 @@ function collectMarqueeKeyframes(content) {
|
||||
function scanCssTextForMarquee(content, markup = content) {
|
||||
const findings = [];
|
||||
if (/<marquee\b/i.test(markup)) {
|
||||
findings.push({ id: 'marquee', snippet: '<marquee> element' });
|
||||
findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
|
||||
}
|
||||
const marqueeKeyframes = collectMarqueeKeyframes(content);
|
||||
if (marqueeKeyframes.size === 0) return findings;
|
||||
@@ -1082,7 +1139,7 @@ function scanCssTextForMarquee(content, markup = content) {
|
||||
const key = `${selector} ${name}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
|
||||
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
@@ -1453,8 +1510,10 @@ function checkHtmlPatterns(html, corpora) {
|
||||
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
||||
if (purpleHexRe.test(styleText)) {
|
||||
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
|
||||
if (purpleTextRe.test(styleText)) {
|
||||
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
|
||||
purpleTextRe.lastIndex = 0;
|
||||
const purpleMatch = purpleTextRe.exec(styleText);
|
||||
if (purpleMatch) {
|
||||
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1465,7 +1524,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
const start = Math.max(0, gm.index - 200);
|
||||
const context = styleText.substring(start, gm.index + gm[0].length + 200);
|
||||
if (/gradient/i.test(context)) {
|
||||
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
|
||||
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1531,7 +1590,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
const animationToken = bounceMatch[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
|
||||
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
|
||||
}
|
||||
|
||||
// Overshoot cubic-bezier
|
||||
@@ -1540,7 +1599,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
while ((bm = bezierRe.exec(styleText)) !== null) {
|
||||
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
|
||||
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
|
||||
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
|
||||
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1573,18 +1632,21 @@ function checkHtmlPatterns(html, corpora) {
|
||||
|
||||
const glowHits = scanCssTextForGlow(styleText);
|
||||
if (glowHits.length > 0) {
|
||||
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
|
||||
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
|
||||
}
|
||||
|
||||
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
|
||||
const haloHits = scanCssTextForRadialHalo(styleText);
|
||||
if (haloHits.length > 0) {
|
||||
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
|
||||
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
|
||||
}
|
||||
|
||||
// --- Generated-UI tells: repeating-gradient stripes ---
|
||||
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
|
||||
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
|
||||
{
|
||||
const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
|
||||
if (stripesMatch) {
|
||||
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Generated-UI tells: two-axis grid-line background ---
|
||||
@@ -1602,7 +1664,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
// whole gradient layers.
|
||||
const gridHits = scanCssTextForGridBackground(styleText);
|
||||
if (gridHits.length > 0) {
|
||||
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
|
||||
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
|
||||
}
|
||||
|
||||
// --- Generated-copy tells: "X theater" framing copy ---
|
||||
@@ -1622,8 +1684,11 @@ function checkHtmlPatterns(html, corpora) {
|
||||
// hover:rotate / hover:translate utility on an <img>. Each distinct
|
||||
// mechanism is its own finding.
|
||||
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
|
||||
if (imgHoverCss.test(styleText)) {
|
||||
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
|
||||
{
|
||||
const imgHoverMatch = imgHoverCss.exec(styleText);
|
||||
if (imgHoverMatch) {
|
||||
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
|
||||
}
|
||||
}
|
||||
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
|
||||
let im;
|
||||
@@ -1670,7 +1735,46 @@ function readOwnBackgroundColor(el, computedStyle) {
|
||||
return bg;
|
||||
}
|
||||
|
||||
function resolveBackground(el, win, customPropMap) {
|
||||
// One element's background-color as the cascade walk sees it: computed style
|
||||
// first (with the modern-color fallback), then, in static mode only,
|
||||
// custom-prop resolution and the inline-shorthand peek. Shared by
|
||||
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
|
||||
// surfaces.
|
||||
function readCascadeBackgroundColor(current, style, customPropMap) {
|
||||
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
|
||||
// The static engine can return literal "var(--X)" / "oklch(...)" strings.
|
||||
// Resolve through customPropMap so Tailwind v4 color tokens become RGB.
|
||||
if (customPropMap) {
|
||||
bg = parseColorResolved(style.backgroundColor, customPropMap);
|
||||
}
|
||||
if (!bg || bg.a < 0.1) {
|
||||
// Inline-style fallback for colors the static cascade did not surface
|
||||
// on backgroundColor.
|
||||
const rawStyle = current.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
||||
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
|
||||
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
|
||||
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
|
||||
}
|
||||
}
|
||||
}
|
||||
return bg;
|
||||
}
|
||||
|
||||
// Walk up for the surface the element's text is painted on.
|
||||
//
|
||||
// Returns { color, unresolved }:
|
||||
// • color set — the effective surface, overlays composited in.
|
||||
// • unresolved: true — a layer on the way up paints a color this parser
|
||||
// cannot read, so the surface is unknown. Callers
|
||||
// must SKIP their contrast checks. Guessing white
|
||||
// here is what flooded dark themes with false
|
||||
// "on #ffffff" findings: one abstention costs a
|
||||
// single finding, one wrong guess costs a hundred.
|
||||
// • both null/false — no solid color, but a gradient or image is in
|
||||
// play; callers fall back to its color stops.
|
||||
function resolveBackgroundInfo(el, win, customPropMap) {
|
||||
let current = el;
|
||||
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
|
||||
// base. A browser composites these over the base; the old behavior
|
||||
@@ -1698,67 +1802,114 @@ function resolveBackground(el, win, customPropMap) {
|
||||
// body backgrounds.
|
||||
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
|
||||
// (e.g. any color-mix() result), which plain parseRgb misses.
|
||||
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
|
||||
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
|
||||
// through customPropMap so Tailwind v4 color tokens become RGB.
|
||||
if (customPropMap) {
|
||||
bg = parseColorResolved(style.backgroundColor, customPropMap);
|
||||
}
|
||||
if (!bg || bg.a < 0.1) {
|
||||
// Inline-style fallback. jsdom doesn't decompose background
|
||||
// shorthand, so colors set via inline style are otherwise invisible.
|
||||
const rawStyle = current.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
||||
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
|
||||
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
|
||||
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
|
||||
}
|
||||
}
|
||||
let bg = readCascadeBackgroundColor(current, style, customPropMap);
|
||||
|
||||
// `background-color: currentcolor` paints with the element's own text
|
||||
// color — real paint whose value we know. Real browsers resolve the
|
||||
// keyword before getComputedStyle output; jsdom hands it through
|
||||
// verbatim, and without this substitution the layer would read as
|
||||
// unparseable and force a needless abstention.
|
||||
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
|
||||
// The static cascade resolves var() text tokens before checks run, so
|
||||
// style.color is normally already an rgb string here; parseColorResolved
|
||||
// is defense in depth for any future caller that passes a live
|
||||
// customPropMap (it matches the text-color path in checkElementColors
|
||||
// and reduces to parseAnyColor when the map is null or absent).
|
||||
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
|
||||
}
|
||||
|
||||
if (bg && bg.a > 0.1) {
|
||||
if (bg.a >= 0.99) return flatten(bg);
|
||||
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
|
||||
overlays.push(bg);
|
||||
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
|
||||
// This layer names a color we could not parse (a color space we do not
|
||||
// model, an unresolved var(), a syntax newer than the parser). It may
|
||||
// well be opaque, which would make every ancestor below it invisible —
|
||||
// so the surface is unknown and the walk stops here rather than
|
||||
// reporting an ancestor the visitor never sees.
|
||||
return { color: null, unresolved: true };
|
||||
}
|
||||
// No solid bg-color at this level. If THIS level has a gradient/url
|
||||
// with no underlying solid color we can read:
|
||||
// • on body/html: assume white. Body-level gradients are almost
|
||||
// always decorative texture (paper grain, noise) on top of a
|
||||
// solid bg-color the page set via `background: var(--paper)`
|
||||
// shorthand — which jsdom can't decompose into bg-color. The
|
||||
// downstream gradient-stops fallback path produces catastrophic
|
||||
// false positives in this case (gradient noise stops have
|
||||
// accidental browns/blacks that look like card backgrounds).
|
||||
// • on other elements: bail to null and let the caller fall back
|
||||
// to gradient stops (gradient buttons / hero sections are real
|
||||
// bgs worth checking against).
|
||||
// No solid bg-color at this level, but this level paints an image. CSS
|
||||
// stacks background-image layers first-on-top, so which layer leads
|
||||
// decides what the visitor sees:
|
||||
// • gradient on top — the gradient is the surface. Hand the caller a
|
||||
// null color so it falls back to the gradient's own stops (body
|
||||
// grounds, gradient buttons, hero sections).
|
||||
// • url() on top — the surface is an image whose pixels this engine
|
||||
// cannot read, and it may fully cover every layer and ancestor
|
||||
// beneath it. Same contract as an unparseable color: abstain, so
|
||||
// the gradient-stop fallback never measures a gradient the image
|
||||
// hides (the shipped miss: `url(photo), linear-gradient(...)`
|
||||
// reported low-contrast against the invisible gradient's stops).
|
||||
if (hasGradientOrUrl) {
|
||||
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
|
||||
return flatten({ r: 255, g: 255, b: 255, a: 1 });
|
||||
const layers = splitTopLevelCommas(bgImage);
|
||||
const topPaintLayer = layers.find(
|
||||
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
|
||||
);
|
||||
const gradientOnTop = !!topPaintLayer
|
||||
&& /gradient\s*\(/i.test(topPaintLayer)
|
||||
&& !/^\s*url\s*\(/i.test(topPaintLayer);
|
||||
if (!gradientOnTop) return { color: null, unresolved: true };
|
||||
// Gradient on top of a url() layer: the image shows through wherever
|
||||
// the gradient is not fully opaque, so a translucent wash like
|
||||
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
|
||||
// a blend with pixels this engine cannot read. Only a gradient whose
|
||||
// every readable stop is opaque provably covers the image; otherwise
|
||||
// the surface is unknown — abstain rather than hand callers gradient
|
||||
// stops (or a stop average) the visitor never sees unmixed.
|
||||
const urlBeneath = layers.some(
|
||||
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
|
||||
);
|
||||
if (urlBeneath) {
|
||||
const topStops = parseGradientColors(topPaintLayer);
|
||||
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
|
||||
if (!provablyOpaque) return { color: null, unresolved: true };
|
||||
}
|
||||
return null;
|
||||
return { color: null, unresolved: false };
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return flatten({ r: 255, g: 255, b: 255, a: 1 });
|
||||
// Every layer up to the document root was genuinely see-through, so the
|
||||
// browser paints its default canvas. This is the ONLY case that earns the
|
||||
// white assumption.
|
||||
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
|
||||
}
|
||||
|
||||
function resolveBackground(el, win, customPropMap) {
|
||||
return resolveBackgroundInfo(el, win, customPropMap).color;
|
||||
}
|
||||
|
||||
// Walk parents looking for a gradient background and return its color stops.
|
||||
// Used as a fallback when resolveBackground() returns null because the
|
||||
// effective background is a gradient (no single solid color to compare against).
|
||||
// Translucent solid layers found between the element and the gradient (frosted
|
||||
// panels, glass washes) are composited over every stop, the same way
|
||||
// resolveBackground flattens them over a solid base — raw stops alone would
|
||||
// false-flag dark text on a light frosted wash over a dark gradient, and miss
|
||||
// the inverse.
|
||||
function resolveGradientStops(el, win, customPropMap) {
|
||||
let current = el;
|
||||
const overlays = [];
|
||||
while (current && current.nodeType === 1) {
|
||||
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
||||
const bgImage = style.backgroundImage || '';
|
||||
// A url() layer anywhere in the stack — alone, or alongside a gradient in
|
||||
// the same declaration (a translucent wash over a texture photo) — paints
|
||||
// pixels the analytic walk cannot know. Measuring the gradient stops over
|
||||
// the wrong base flagged dark ink sitting on a bright gold-leaf image at
|
||||
// 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
|
||||
// image-backed text.
|
||||
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
|
||||
let stops = null;
|
||||
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
||||
// parseGradientColors (shared) reads modern-space stops too — oklch,
|
||||
// color-mix and friends via balanced-paren token capture — so browser
|
||||
// computed values that keep the authored syntax stay measurable.
|
||||
const parsed = parseGradientColors(bgImage);
|
||||
if (parsed.length > 0) stops = parsed;
|
||||
}
|
||||
if (!stops && !DETECTOR_IS_BROWSER) {
|
||||
// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
|
||||
// Static mode: peek at the raw inline style for gradients the cascade did not surface
|
||||
const rawStyle = current.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
||||
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
||||
@@ -1766,7 +1917,23 @@ function resolveGradientStops(el, win, customPropMap) {
|
||||
if (parsed.length > 0) stops = parsed;
|
||||
}
|
||||
}
|
||||
if (stops) return compositeGradientStops(stops, current, win, customPropMap);
|
||||
if (stops) {
|
||||
const composited = compositeGradientStops(stops, current, win, customPropMap);
|
||||
if (!composited || overlays.length === 0) return composited;
|
||||
return composited.map(stop => {
|
||||
let acc = stop;
|
||||
for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
|
||||
return acc;
|
||||
});
|
||||
}
|
||||
const bg = readCascadeBackgroundColor(current, style, customPropMap);
|
||||
if (bg && bg.a > 0.1) {
|
||||
// An opaque surface above the gradient means the gradient never shows
|
||||
// through here; resolveBackground would have returned it, so reaching
|
||||
// this is defensive — bail rather than measure the wrong layer.
|
||||
if (bg.a >= 0.99) return null;
|
||||
overlays.push(bg);
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
@@ -1986,15 +2153,25 @@ function checkElementColorsDOM(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 10 || rect.height < 10) return [];
|
||||
const style = getComputedStyle(el);
|
||||
// Invisible at rest: hidden scene variants (opacity-0 carousels, swap
|
||||
// decks) are not user-visible, and measuring their inherited colors against
|
||||
// whatever surface happens to sit behind the stack is noise, not audit.
|
||||
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
|
||||
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||
const hasDirectText = directText.trim().length > 0;
|
||||
let effectiveBg = resolveBackground(el);
|
||||
const bgInfo = resolveBackgroundInfo(el);
|
||||
let effectiveBg = bgInfo.color;
|
||||
// An unreadable surface anywhere up the chain: skip the gradient-stop
|
||||
// fallback too, so nothing downstream measures against a ground we never
|
||||
// resolved.
|
||||
let surfaceUnresolved = bgInfo.unresolved;
|
||||
let ownBg = readOwnBackgroundColor(el, style);
|
||||
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
|
||||
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
|
||||
if (pseudoSurface) {
|
||||
ownBg = pseudoSurface;
|
||||
effectiveBg = pseudoSurface;
|
||||
surfaceUnresolved = false;
|
||||
}
|
||||
}
|
||||
return checkColors({
|
||||
@@ -2006,8 +2183,8 @@ function checkElementColorsDOM(el) {
|
||||
// an oklch token near its own oklch background).
|
||||
textColor: parseRgb(style.color) || parseAnyColor(style.color),
|
||||
bgColor: ownBg,
|
||||
effectiveBg,
|
||||
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
|
||||
effectiveBg: surfaceUnresolved ? null : effectiveBg,
|
||||
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
|
||||
fontSize: parseFloat(style.fontSize) || 16,
|
||||
fontWeight: parseInt(style.fontWeight) || 400,
|
||||
hasDirectText,
|
||||
@@ -2157,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
|
||||
});
|
||||
}
|
||||
|
||||
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
|
||||
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
|
||||
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
|
||||
// returns the literal "oklch(...)" string. Without this, the entire
|
||||
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
|
||||
// detector's contrast / color checks.
|
||||
function oklchToRgb(L, C, H) {
|
||||
const hRad = (H * Math.PI) / 180;
|
||||
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
|
||||
}
|
||||
|
||||
function oklabToRgb(L, a, b) {
|
||||
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
||||
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
|
||||
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
|
||||
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
|
||||
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
|
||||
const enc = (x) => {
|
||||
const c = Math.max(0, Math.min(1, x));
|
||||
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
||||
};
|
||||
return {
|
||||
r: Math.round(enc(rLin) * 255),
|
||||
g: Math.round(enc(gLin) * 255),
|
||||
b: Math.round(enc(bLin) * 255),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m0 = l - c / 2;
|
||||
const [r, g, b] =
|
||||
h < 60 ? [c, x, 0] :
|
||||
h < 120 ? [x, c, 0] :
|
||||
h < 180 ? [0, c, x] :
|
||||
h < 240 ? [0, x, c] :
|
||||
h < 300 ? [x, 0, c] : [c, 0, x];
|
||||
return {
|
||||
r: Math.round((r + m0) * 255),
|
||||
g: Math.round((g + m0) * 255),
|
||||
b: Math.round((b + m0) * 255),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function hwbToRgb(h, w, bl) {
|
||||
if (w + bl >= 1) {
|
||||
const g = Math.round((w / (w + bl)) * 255);
|
||||
return { r: g, g, b: g, a: 1 };
|
||||
}
|
||||
const base = hslToRgb(h, 1, 0.5);
|
||||
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
|
||||
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
|
||||
}
|
||||
|
||||
// Common CSS named colors — the handful that actually show up in generated
|
||||
// UIs, not the full 148-name spec list. Includes the achromatic names so a
|
||||
// named gray parses (and correctly reads as no-chroma) instead of being
|
||||
// treated as an unknown color.
|
||||
const CSS_NAMED_COLORS = {
|
||||
black: { r: 0, g: 0, b: 0 },
|
||||
white: { r: 255, g: 255, b: 255 },
|
||||
gray: { r: 128, g: 128, b: 128 },
|
||||
grey: { r: 128, g: 128, b: 128 },
|
||||
silver: { r: 192, g: 192, b: 192 },
|
||||
dimgray: { r: 105, g: 105, b: 105 },
|
||||
darkgray: { r: 169, g: 169, b: 169 },
|
||||
lightgray: { r: 211, g: 211, b: 211 },
|
||||
gainsboro: { r: 220, g: 220, b: 220 },
|
||||
whitesmoke: { r: 245, g: 245, b: 245 },
|
||||
red: { r: 255, g: 0, b: 0 },
|
||||
crimson: { r: 220, g: 20, b: 60 },
|
||||
tomato: { r: 255, g: 99, b: 71 },
|
||||
coral: { r: 255, g: 127, b: 80 },
|
||||
salmon: { r: 250, g: 128, b: 114 },
|
||||
orange: { r: 255, g: 165, b: 0 },
|
||||
gold: { r: 255, g: 215, b: 0 },
|
||||
yellow: { r: 255, g: 255, b: 0 },
|
||||
olive: { r: 128, g: 128, b: 0 },
|
||||
lime: { r: 0, g: 255, b: 0 },
|
||||
green: { r: 0, g: 128, b: 0 },
|
||||
teal: { r: 0, g: 128, b: 128 },
|
||||
turquoise: { r: 64, g: 224, b: 208 },
|
||||
cyan: { r: 0, g: 255, b: 255 },
|
||||
aqua: { r: 0, g: 255, b: 255 },
|
||||
skyblue: { r: 135, g: 206, b: 235 },
|
||||
dodgerblue: { r: 30, g: 144, b: 255 },
|
||||
blue: { r: 0, g: 0, b: 255 },
|
||||
navy: { r: 0, g: 0, b: 128 },
|
||||
indigo: { r: 75, g: 0, b: 130 },
|
||||
rebeccapurple: { r: 102, g: 51, b: 153 },
|
||||
purple: { r: 128, g: 0, b: 128 },
|
||||
violet: { r: 238, g: 130, b: 238 },
|
||||
orchid: { r: 218, g: 112, b: 214 },
|
||||
magenta: { r: 255, g: 0, b: 255 },
|
||||
fuchsia: { r: 255, g: 0, b: 255 },
|
||||
hotpink: { r: 255, g: 105, b: 180 },
|
||||
pink: { r: 255, g: 192, b: 203 },
|
||||
maroon: { r: 128, g: 0, b: 0 },
|
||||
};
|
||||
|
||||
// Split a string on top-level commas (ignoring commas nested in parens).
|
||||
function splitTopLevelCommas(str) {
|
||||
const parts = [];
|
||||
let depth = 0, start = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str[i];
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') depth = Math.max(0, depth - 1);
|
||||
else if (ch === ',' && depth === 0) {
|
||||
parts.push(str.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
const tail = str.slice(start).trim();
|
||||
if (tail) parts.push(tail);
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
|
||||
// the expression can't be resolved (unresolved var(), unknown colors).
|
||||
//
|
||||
// Mixing is done with premultiplied alpha in sRGB regardless of the
|
||||
// declared interpolation space. That is exact for the dominant generated-UI
|
||||
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
|
||||
// result is simply <color> at alpha N% in ANY rectangular space, and a
|
||||
// close-enough approximation for opaque-opaque mixes (the detector only
|
||||
// consumes these values for contrast/chroma thresholds, not for display).
|
||||
function parseColorMix(str) {
|
||||
const m = String(str).trim().match(/^color-mix\(/i);
|
||||
if (!m) return null;
|
||||
// Balanced-paren capture of the arguments.
|
||||
let depth = 0, end = -1;
|
||||
const open = str.indexOf('(');
|
||||
for (let i = open; i < str.length; i++) {
|
||||
if (str[i] === '(') depth++;
|
||||
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
if (end < 0) return null;
|
||||
const args = splitTopLevelCommas(str.slice(open + 1, end));
|
||||
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
|
||||
|
||||
const parseComponent = (component) => {
|
||||
// Percentage may lead or trail the color per spec.
|
||||
let pct = null;
|
||||
let colorStr = component;
|
||||
const trail = component.match(/\s+([\d.]+)%$/);
|
||||
const lead = component.match(/^([\d.]+)%\s+/);
|
||||
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
|
||||
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
|
||||
let color;
|
||||
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
|
||||
else color = parseAnyColor(colorStr);
|
||||
if (!color) return null;
|
||||
return { color, pct };
|
||||
};
|
||||
|
||||
const c1 = parseComponent(args[1]);
|
||||
const c2 = parseComponent(args[2]);
|
||||
if (!c1 || !c2) return null;
|
||||
let p1 = c1.pct, p2 = c2.pct;
|
||||
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
|
||||
else if (p1 == null) p1 = 100 - p2;
|
||||
else if (p2 == null) p2 = 100 - p1;
|
||||
const sum = p1 + p2;
|
||||
if (sum <= 0) return null;
|
||||
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
|
||||
// additionally scaled by sum/100.
|
||||
const w1 = p1 / sum, w2 = p2 / sum;
|
||||
const alphaScale = sum < 100 ? sum / 100 : 1;
|
||||
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
|
||||
const a = (a1 * w1 + a2 * w2) * alphaScale;
|
||||
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
|
||||
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
|
||||
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
|
||||
}
|
||||
|
||||
// Composite a translucent color over an opaque(ish) base (simple
|
||||
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
|
||||
function compositeColorOver(top, base) {
|
||||
const a = top.a ?? 1;
|
||||
return {
|
||||
r: Math.round(top.r * a + base.r * (1 - a)),
|
||||
g: Math.round(top.g * a + base.g * (1 - a)),
|
||||
b: Math.round(top.b * a + base.b * (1 - a)),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
|
||||
// named colors. Returns null on no match. Use this when the input might be
|
||||
// any CSS color form; use plain parseRgb when you only expect computed rgb()
|
||||
// values from real browsers.
|
||||
function parseAnyColor(s) {
|
||||
if (!s || typeof s !== 'string') return null;
|
||||
const str = s.trim();
|
||||
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
|
||||
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
|
||||
let m;
|
||||
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
|
||||
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
|
||||
m = str.match(/^#([0-9a-f]{3,8})$/i);
|
||||
if (m) {
|
||||
const h = m[1];
|
||||
if (h.length === 3 || h.length === 4) {
|
||||
return {
|
||||
r: parseInt(h[0] + h[0], 16),
|
||||
g: parseInt(h[1] + h[1], 16),
|
||||
b: parseInt(h[2] + h[2], 16),
|
||||
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
|
||||
};
|
||||
}
|
||||
if (h.length === 6 || h.length === 8) {
|
||||
return {
|
||||
r: parseInt(h.slice(0, 2), 16),
|
||||
g: parseInt(h.slice(2, 4), 16),
|
||||
b: parseInt(h.slice(4, 6), 16),
|
||||
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
||||
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
||||
// Match L (with optional %), then C and H separated permissively.
|
||||
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const Lnum = parseFloat(m[1]);
|
||||
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
||||
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
||||
if (m[5] !== undefined) {
|
||||
const alpha = parseFloat(m[5]);
|
||||
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
|
||||
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
|
||||
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
|
||||
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
|
||||
const rgb = oklabToRgb(L, a, b);
|
||||
if (m[7] !== undefined) {
|
||||
const alpha = parseFloat(m[7]);
|
||||
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
// HSL/HSLA — comma or space syntax, optional deg on hue.
|
||||
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
|
||||
if (m[4] !== undefined) {
|
||||
const alpha = parseFloat(m[4]);
|
||||
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
// HWB — hue whiteness% blackness%.
|
||||
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
|
||||
if (m[4] !== undefined) {
|
||||
const alpha = parseFloat(m[4]);
|
||||
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
const named = CSS_NAMED_COLORS[str.toLowerCase()];
|
||||
if (named) return { ...named, a: 1 };
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve var() refs in a color string (via customPropMap), then parse.
|
||||
// Returns null on any failure. Used in jsdom-mode paths where
|
||||
@@ -2796,9 +2696,20 @@ function checkElementGlowDOM(el) {
|
||||
if (!boxShadow && !textShadow) return [];
|
||||
// Use parent's background — glow radiates outward, so the surrounding context matters
|
||||
// If resolveBackground returns null (gradient), try to infer from the gradient colors
|
||||
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
|
||||
if (!parentBg) {
|
||||
// Gradient background — sample its colors to determine if it's dark
|
||||
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
|
||||
// Unknown surface (an unreadable layer on the way up): skip only the
|
||||
// gradient hunt below, which would walk PAST that layer and score the
|
||||
// glow against a background the visitor never sees. checkGlow still runs
|
||||
// with a null surface: the zero-offset chromatic halo tell holds on ANY
|
||||
// background, and the static loop already passes the unresolved walk's
|
||||
// null color straight through (detect-html.mjs uses resolveBackground).
|
||||
let parentBg = parentBgInfo.color;
|
||||
if (!parentBg && !parentBgInfo.unresolved) {
|
||||
// Gradient background — sample its colors to determine if it's dark.
|
||||
// Modern-syntax parsing matters here: body-level gradients now reach this
|
||||
// fallback in browser mode, and their stops usually serialize as oklch —
|
||||
// which the shared parseGradientColors reads via its color-function
|
||||
// token capture.
|
||||
let cur = el.parentElement;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const bgImage = getComputedStyle(cur).backgroundImage || '';
|
||||
@@ -2846,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
if (isAIPalette) {
|
||||
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
|
||||
// Also check gradient parents
|
||||
let effectiveBg = parentBg;
|
||||
if (!effectiveBg) {
|
||||
const parentBgInfo = el.parentElement
|
||||
? resolveBackgroundInfo(el.parentElement)
|
||||
: { color: null, unresolved: false };
|
||||
// Unknown surface: leave effectiveBg null (no finding) rather than
|
||||
// hunting gradient ancestors past a layer we could not read.
|
||||
let effectiveBg = parentBgInfo.color;
|
||||
if (!effectiveBg && !parentBgInfo.unresolved) {
|
||||
let cur = el.parentElement;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const gi = getComputedStyle(cur).backgroundImage || '';
|
||||
@@ -3644,10 +3558,19 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
|
||||
}
|
||||
|
||||
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
|
||||
// Invisible at rest, static twin of the browser walk's skip: opacity does
|
||||
// not inherit, so walk ancestors multiplying declared opacity down.
|
||||
if (style.visibility === 'hidden') return [];
|
||||
let effOpacity = 1;
|
||||
for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
|
||||
effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
|
||||
}
|
||||
if (effOpacity <= 0.02) return [];
|
||||
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||
const hasDirectText = directText.trim().length > 0;
|
||||
|
||||
const effectiveBg = resolveBackground(el, window, customPropMap);
|
||||
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
|
||||
const effectiveBg = bgInfo.color;
|
||||
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
|
||||
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
|
||||
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
|
||||
@@ -3693,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
||||
// element itself has no usable own background, that pseudo is the real
|
||||
// surface for contrast purposes.
|
||||
let finalEffectiveBg = effectiveBg;
|
||||
let surfaceUnresolved = bgInfo.unresolved;
|
||||
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
|
||||
const pseudoSurface = window.getPseudoSurface(el);
|
||||
if (pseudoSurface) {
|
||||
ownBg = pseudoSurface;
|
||||
finalEffectiveBg = pseudoSurface;
|
||||
surfaceUnresolved = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3705,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
||||
tag,
|
||||
textColor,
|
||||
bgColor: ownBg,
|
||||
effectiveBg: finalEffectiveBg,
|
||||
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
|
||||
// Unknown surface: hand the checks nothing rather than a guess.
|
||||
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
|
||||
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
|
||||
fontSize: parseFloat(style.fontSize) || 16,
|
||||
fontWeight: parseInt(style.fontWeight) || 400,
|
||||
hasDirectText,
|
||||
@@ -4802,6 +4728,11 @@ function isRenderedForBrowserRule(el) {
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
// scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
|
||||
// returns arbitrary non-zero values for both (a <text> reported 78/48 while
|
||||
// its rendered length sat comfortably inside its box), so the delta is
|
||||
// noise, not overflow. SVG clips to its own viewport anyway.
|
||||
if (el.namespaceURI === 'http://www.w3.org/2000/svg') return [];
|
||||
if (!isRenderedForBrowserRule(el)) return [];
|
||||
// Only the element that actually owns overflowing text — not its ancestors,
|
||||
// which inherit a wider scrollWidth from the spilling descendant.
|
||||
@@ -5186,6 +5117,22 @@ function isPaintedForOcclusion(el) {
|
||||
// path is pure geometry and runs anywhere on the page.
|
||||
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
|
||||
|
||||
// An element whose effective opacity multiplies out to ~0 paints nothing at
|
||||
// rest: it is not user-visible, so visual findings on it (contrast, occlusion)
|
||||
// measure a state nobody sees. Browser-only — the walk needs live computed
|
||||
// styles. Cycling scenes that fade such elements in later are the screenshot
|
||||
// subsystem's territory, not the analytic walk's.
|
||||
function effectiveOpacityDOM(el) {
|
||||
let o = 1;
|
||||
// Walk all the way through body and html: `body { opacity: 0 }` page-fade
|
||||
// wrappers hide every descendant just as thoroughly as a local wrapper.
|
||||
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
|
||||
o *= parseFloat(getComputedStyle(cur).opacity || '1');
|
||||
if (o <= 0.02) return 0;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function checkTextOcclusionDOM() {
|
||||
const findings = [];
|
||||
const seenVictims = new Set();
|
||||
@@ -5213,6 +5160,11 @@ function checkTextOcclusionDOM() {
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// The classic occluder shape this rules out is an opacity-0 interaction
|
||||
// layer — a range scrubber stretched over a before/after comparison — which
|
||||
// elementFromPoint still returns and whose UA background-color otherwise
|
||||
// reads as an opaque box.
|
||||
const effectiveOpacity = effectiveOpacityDOM;
|
||||
|
||||
// Collect renderable text owners in / near the first viewport for the
|
||||
// elementFromPoint probe. SVG <text> counts too.
|
||||
@@ -5225,6 +5177,7 @@ function checkTextOcclusionDOM() {
|
||||
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
|
||||
if (text.length < 2) continue;
|
||||
if (!isPaintedForOcclusion(el)) continue;
|
||||
if (effectiveOpacity(el) <= 0.02) continue;
|
||||
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
|
||||
if (rect.width < 6 || rect.height < 6) continue;
|
||||
// Viewport-bound probe: keep text whose box overlaps the live viewport.
|
||||
@@ -5258,6 +5211,7 @@ function checkTextOcclusionDOM() {
|
||||
if (top === el || el.contains(top) || top.contains(el)) continue;
|
||||
const topCs = getComputedStyle(top);
|
||||
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
|
||||
if (effectiveOpacity(top) <= 0.02) continue;
|
||||
const topTag = top.tagName.toLowerCase();
|
||||
// Text sitting under a raw image/video is contrast territory (deduped
|
||||
// against the pixel low-contrast rule); leave those alone here.
|
||||
@@ -5468,6 +5422,7 @@ export {
|
||||
CSS_NAMED_COLORS,
|
||||
checkBorders,
|
||||
isEmojiOnlyText,
|
||||
scopedIgnoreActive,
|
||||
checkColors,
|
||||
checkHoverContrast,
|
||||
checkElementHoverContrast,
|
||||
@@ -5497,6 +5452,7 @@ export {
|
||||
checkHtmlPatterns,
|
||||
readOwnBackgroundColor,
|
||||
resolveBackground,
|
||||
resolveBackgroundInfo,
|
||||
resolveGradientStops,
|
||||
parseRadiusToPx,
|
||||
resolveBorderRadiusPx,
|
||||
|
||||
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
// The CSS color functions worth pulling out of a longer declaration. The set
|
||||
// is deliberately closed: `linear-gradient(` and `url(` also look like
|
||||
// `name(` and must not be read as colors.
|
||||
const COLOR_FUNCTION_NAMES = new Set([
|
||||
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
|
||||
]);
|
||||
|
||||
// Pull every color-function token out of a value, with balanced-paren capture
|
||||
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
|
||||
// whole. Returns the raw substrings in source order.
|
||||
function extractColorFunctionTokens(value) {
|
||||
const str = String(value || '');
|
||||
const tokens = [];
|
||||
const re = /([a-z][a-z-]*)\(/gi;
|
||||
let m;
|
||||
while ((m = re.exec(str)) !== null) {
|
||||
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
|
||||
let depth = 0, end = -1;
|
||||
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
|
||||
if (str[i] === '(') depth++;
|
||||
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
if (end < 0) break;
|
||||
tokens.push(str.slice(m.index, end + 1));
|
||||
re.lastIndex = end + 1;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function parseGradientColors(bgImage) {
|
||||
if (!bgImage || !bgImage.includes('gradient')) return [];
|
||||
const colors = [];
|
||||
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
|
||||
const c = parseRgb(m[0]);
|
||||
// Stops arrive in whatever syntax the author wrote and the browser kept.
|
||||
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
|
||||
// to read as a gradient with no stops at all.
|
||||
for (const token of extractColorFunctionTokens(bgImage)) {
|
||||
const c = parseAnyColor(token);
|
||||
if (c) colors.push(c);
|
||||
}
|
||||
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
|
||||
@@ -112,13 +144,445 @@ function colorToHex(c) {
|
||||
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// ─── Color-space conversions ────────────────────────────────────────────────
|
||||
//
|
||||
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
|
||||
// and Firefox all keep the authored color space in getComputedStyle output
|
||||
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
|
||||
// so a detector that only reads rgb() is blind on any modern palette. The
|
||||
// expected outputs are pinned in tests/detect-antipatterns.test.js against
|
||||
// what Chrome itself paints for the same strings.
|
||||
|
||||
function clamp01(x) {
|
||||
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
|
||||
}
|
||||
|
||||
// Linear-light sRGB channel to the encoded 0-255 value.
|
||||
function encodeSrgbChannel(x) {
|
||||
const c = clamp01(x);
|
||||
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
|
||||
}
|
||||
|
||||
function decodeSrgbChannel(x) {
|
||||
const c = Number.isFinite(x) ? x : 0;
|
||||
const sign = c < 0 ? -1 : 1;
|
||||
const abs = Math.abs(c);
|
||||
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
|
||||
}
|
||||
|
||||
function linearSrgbToColor(r, g, b, a = 1) {
|
||||
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
|
||||
}
|
||||
|
||||
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
|
||||
function oklabToRgb(L, a, b) {
|
||||
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
||||
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
|
||||
return linearSrgbToColor(
|
||||
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
|
||||
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
|
||||
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
|
||||
);
|
||||
}
|
||||
|
||||
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
|
||||
// the sRGB gamut clamps per channel rather than producing NaN.
|
||||
function oklchToRgb(L, C, H) {
|
||||
const hRad = (H * Math.PI) / 180;
|
||||
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
|
||||
}
|
||||
|
||||
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
|
||||
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
|
||||
function labToRgb(L, a, b) {
|
||||
const kappa = 24389 / 27, epsilon = 216 / 24389;
|
||||
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
|
||||
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
|
||||
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
|
||||
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
|
||||
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
|
||||
return linearSrgbToColor(
|
||||
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
|
||||
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
|
||||
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
|
||||
);
|
||||
}
|
||||
|
||||
function lchToRgb(L, C, H) {
|
||||
const hRad = (H * Math.PI) / 180;
|
||||
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
|
||||
}
|
||||
|
||||
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
|
||||
// `srgb` is what Chrome serializes most color-mix() results into, routinely
|
||||
// with channels outside 0..1. Spaces we do not model return null so callers
|
||||
// abstain instead of measuring against a color we invented.
|
||||
function colorFunctionToRgb(space, c1, c2, c3) {
|
||||
switch (space) {
|
||||
case 'srgb':
|
||||
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
|
||||
case 'srgb-linear':
|
||||
return linearSrgbToColor(c1, c2, c3);
|
||||
case 'display-p3': {
|
||||
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
|
||||
return linearSrgbToColor(
|
||||
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
|
||||
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
|
||||
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m0 = l - c / 2;
|
||||
const [r, g, b] =
|
||||
h < 60 ? [c, x, 0] :
|
||||
h < 120 ? [x, c, 0] :
|
||||
h < 180 ? [0, c, x] :
|
||||
h < 240 ? [0, x, c] :
|
||||
h < 300 ? [x, 0, c] : [c, 0, x];
|
||||
return {
|
||||
r: Math.round((r + m0) * 255),
|
||||
g: Math.round((g + m0) * 255),
|
||||
b: Math.round((b + m0) * 255),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function hwbToRgb(h, w, bl) {
|
||||
if (w + bl >= 1) {
|
||||
const g = Math.round((w / (w + bl)) * 255);
|
||||
return { r: g, g, b: g, a: 1 };
|
||||
}
|
||||
const base = hslToRgb(h, 1, 0.5);
|
||||
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
|
||||
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
|
||||
}
|
||||
|
||||
// Common CSS named colors — the handful that actually show up in generated
|
||||
// UIs, not the full 148-name spec list. Includes the achromatic names so a
|
||||
// named gray parses (and correctly reads as no-chroma) instead of being
|
||||
// treated as an unknown color.
|
||||
const CSS_NAMED_COLORS = {
|
||||
black: { r: 0, g: 0, b: 0 },
|
||||
white: { r: 255, g: 255, b: 255 },
|
||||
gray: { r: 128, g: 128, b: 128 },
|
||||
grey: { r: 128, g: 128, b: 128 },
|
||||
silver: { r: 192, g: 192, b: 192 },
|
||||
dimgray: { r: 105, g: 105, b: 105 },
|
||||
darkgray: { r: 169, g: 169, b: 169 },
|
||||
lightgray: { r: 211, g: 211, b: 211 },
|
||||
gainsboro: { r: 220, g: 220, b: 220 },
|
||||
whitesmoke: { r: 245, g: 245, b: 245 },
|
||||
red: { r: 255, g: 0, b: 0 },
|
||||
crimson: { r: 220, g: 20, b: 60 },
|
||||
tomato: { r: 255, g: 99, b: 71 },
|
||||
coral: { r: 255, g: 127, b: 80 },
|
||||
salmon: { r: 250, g: 128, b: 114 },
|
||||
orange: { r: 255, g: 165, b: 0 },
|
||||
gold: { r: 255, g: 215, b: 0 },
|
||||
yellow: { r: 255, g: 255, b: 0 },
|
||||
olive: { r: 128, g: 128, b: 0 },
|
||||
lime: { r: 0, g: 255, b: 0 },
|
||||
green: { r: 0, g: 128, b: 0 },
|
||||
teal: { r: 0, g: 128, b: 128 },
|
||||
turquoise: { r: 64, g: 224, b: 208 },
|
||||
cyan: { r: 0, g: 255, b: 255 },
|
||||
aqua: { r: 0, g: 255, b: 255 },
|
||||
skyblue: { r: 135, g: 206, b: 235 },
|
||||
dodgerblue: { r: 30, g: 144, b: 255 },
|
||||
blue: { r: 0, g: 0, b: 255 },
|
||||
navy: { r: 0, g: 0, b: 128 },
|
||||
indigo: { r: 75, g: 0, b: 130 },
|
||||
rebeccapurple: { r: 102, g: 51, b: 153 },
|
||||
purple: { r: 128, g: 0, b: 128 },
|
||||
violet: { r: 238, g: 130, b: 238 },
|
||||
orchid: { r: 218, g: 112, b: 214 },
|
||||
magenta: { r: 255, g: 0, b: 255 },
|
||||
fuchsia: { r: 255, g: 0, b: 255 },
|
||||
hotpink: { r: 255, g: 105, b: 180 },
|
||||
pink: { r: 255, g: 192, b: 203 },
|
||||
maroon: { r: 128, g: 0, b: 0 },
|
||||
};
|
||||
|
||||
// Split a string on top-level commas (ignoring commas nested in parens).
|
||||
function splitTopLevelCommas(str) {
|
||||
const parts = [];
|
||||
let depth = 0, start = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str[i];
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') depth = Math.max(0, depth - 1);
|
||||
else if (ch === ',' && depth === 0) {
|
||||
parts.push(str.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
const tail = str.slice(start).trim();
|
||||
if (tail) parts.push(tail);
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
|
||||
// the expression can't be resolved (unresolved var(), unknown colors).
|
||||
//
|
||||
// Mixing is done with premultiplied alpha in sRGB regardless of the
|
||||
// declared interpolation space. That is exact for the dominant generated-UI
|
||||
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
|
||||
// result is simply <color> at alpha N% in ANY rectangular space, and a
|
||||
// close-enough approximation for opaque-opaque mixes (the detector only
|
||||
// consumes these values for contrast/chroma thresholds, not for display).
|
||||
function parseColorMix(str) {
|
||||
const m = String(str).trim().match(/^color-mix\(/i);
|
||||
if (!m) return null;
|
||||
// Balanced-paren capture of the arguments.
|
||||
let depth = 0, end = -1;
|
||||
const open = str.indexOf('(');
|
||||
for (let i = open; i < str.length; i++) {
|
||||
if (str[i] === '(') depth++;
|
||||
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
if (end < 0) return null;
|
||||
const args = splitTopLevelCommas(str.slice(open + 1, end));
|
||||
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
|
||||
|
||||
const parseComponent = (component) => {
|
||||
// Percentage may lead or trail the color per spec.
|
||||
let pct = null;
|
||||
let colorStr = component;
|
||||
const trail = component.match(/\s+([\d.]+)%$/);
|
||||
const lead = component.match(/^([\d.]+)%\s+/);
|
||||
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
|
||||
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
|
||||
let color;
|
||||
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
|
||||
else color = parseAnyColor(colorStr);
|
||||
if (!color) return null;
|
||||
return { color, pct };
|
||||
};
|
||||
|
||||
const c1 = parseComponent(args[1]);
|
||||
const c2 = parseComponent(args[2]);
|
||||
if (!c1 || !c2) return null;
|
||||
let p1 = c1.pct, p2 = c2.pct;
|
||||
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
|
||||
else if (p1 == null) p1 = 100 - p2;
|
||||
else if (p2 == null) p2 = 100 - p1;
|
||||
const sum = p1 + p2;
|
||||
if (sum <= 0) return null;
|
||||
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
|
||||
// additionally scaled by sum/100.
|
||||
const w1 = p1 / sum, w2 = p2 / sum;
|
||||
const alphaScale = sum < 100 ? sum / 100 : 1;
|
||||
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
|
||||
const a = (a1 * w1 + a2 * w2) * alphaScale;
|
||||
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
|
||||
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
|
||||
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
|
||||
}
|
||||
|
||||
// Composite a translucent color over an opaque(ish) base (simple
|
||||
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
|
||||
function compositeColorOver(top, base) {
|
||||
const a = top.a ?? 1;
|
||||
return {
|
||||
r: Math.round(top.r * a + base.r * (1 - a)),
|
||||
g: Math.round(top.g * a + base.g * (1 - a)),
|
||||
b: Math.round(top.b * a + base.b * (1 - a)),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// A color() / lab() / lch() component: a bare number, a percentage against
|
||||
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
|
||||
function parseColorComponent(token, scale = 1) {
|
||||
if (token == null) return null;
|
||||
const t = String(token).trim();
|
||||
if (/^none$/i.test(t)) return 0;
|
||||
const num = parseFloat(t);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return t.endsWith('%') ? (num / 100) * scale : num;
|
||||
}
|
||||
|
||||
function parseAlphaToken(token) {
|
||||
if (token == null) return 1;
|
||||
const t = String(token).trim();
|
||||
if (/^none$/i.test(t)) return 1;
|
||||
const num = parseFloat(t);
|
||||
if (!Number.isFinite(num)) return 1;
|
||||
return t.endsWith('%') ? num / 100 : num;
|
||||
}
|
||||
|
||||
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
|
||||
// color-mix/common named colors. Returns null on no match. Use this when the
|
||||
// input might be any CSS color form; use plain parseRgb when you only expect
|
||||
// computed rgb() values from real browsers.
|
||||
function parseAnyColor(s) {
|
||||
if (!s || typeof s !== 'string') return null;
|
||||
const str = s.trim();
|
||||
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
|
||||
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
|
||||
let m;
|
||||
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
|
||||
if (m) {
|
||||
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
|
||||
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
|
||||
return c;
|
||||
}
|
||||
m = str.match(/^#([0-9a-f]{3,8})$/i);
|
||||
if (m) {
|
||||
const h = m[1];
|
||||
if (h.length === 3 || h.length === 4) {
|
||||
return {
|
||||
r: parseInt(h[0] + h[0], 16),
|
||||
g: parseInt(h[1] + h[1], 16),
|
||||
b: parseInt(h[2] + h[2], 16),
|
||||
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
|
||||
};
|
||||
}
|
||||
if (h.length === 6 || h.length === 8) {
|
||||
return {
|
||||
r: parseInt(h.slice(0, 2), 16),
|
||||
g: parseInt(h.slice(2, 4), 16),
|
||||
b: parseInt(h.slice(4, 6), 16),
|
||||
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
||||
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
||||
// Match L (with optional %), then C and H separated permissively.
|
||||
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const Lnum = parseFloat(m[1]);
|
||||
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
||||
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
||||
if (m[5] !== undefined) {
|
||||
const alpha = parseFloat(m[5]);
|
||||
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
|
||||
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
|
||||
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
|
||||
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
|
||||
const rgb = oklabToRgb(L, a, b);
|
||||
if (m[7] !== undefined) {
|
||||
const alpha = parseFloat(m[7]);
|
||||
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
|
||||
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
|
||||
// spaces L runs 0..100 and 100% means 100.
|
||||
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
|
||||
if (m) {
|
||||
const L = parseColorComponent(m[1], 100);
|
||||
const C = parseColorComponent(m[2], 150);
|
||||
const H = parseFloat(m[3]);
|
||||
if (L == null || C == null || !Number.isFinite(H)) return null;
|
||||
const rgb = lchToRgb(L, C, H);
|
||||
rgb.a = parseAlphaToken(m[4]);
|
||||
return rgb;
|
||||
}
|
||||
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
|
||||
if (m) {
|
||||
const L = parseColorComponent(m[1], 100);
|
||||
const a = parseColorComponent(m[2], 125);
|
||||
const b = parseColorComponent(m[3], 125);
|
||||
if (L == null || a == null || b == null) return null;
|
||||
const rgb = labToRgb(L, a, b);
|
||||
rgb.a = parseAlphaToken(m[4]);
|
||||
return rgb;
|
||||
}
|
||||
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
|
||||
// color-mix() results and for any wide-gamut color an author wrote.
|
||||
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
|
||||
if (m) {
|
||||
const c1 = parseColorComponent(m[2]);
|
||||
const c2 = parseColorComponent(m[3]);
|
||||
const c3 = parseColorComponent(m[4]);
|
||||
if (c1 == null || c2 == null || c3 == null) return null;
|
||||
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
|
||||
if (!rgb) return null;
|
||||
rgb.a = parseAlphaToken(m[5]);
|
||||
return rgb;
|
||||
}
|
||||
// HSL/HSLA — comma or space syntax, optional deg on hue.
|
||||
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
|
||||
if (m[4] !== undefined) {
|
||||
const alpha = parseFloat(m[4]);
|
||||
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
// HWB — hue whiteness% blackness%.
|
||||
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
|
||||
if (m[4] !== undefined) {
|
||||
const alpha = parseFloat(m[4]);
|
||||
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
const named = CSS_NAMED_COLORS[str.toLowerCase()];
|
||||
if (named) return { ...named, a: 1 };
|
||||
return null;
|
||||
}
|
||||
|
||||
// True when a computed background-color string names no paint at all. Used to
|
||||
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
|
||||
// layer has a color we could not read" (stop and abstain).
|
||||
//
|
||||
// `inherit` belongs here even though it is not literally see-through: it means
|
||||
// "paint with the parent's background-color", and walking on to the parent IS
|
||||
// that resolution. Real browsers resolve the keyword before getComputedStyle
|
||||
// output; only jsdom's partial cascade hands it through verbatim, and treating
|
||||
// it as unreadable would make the walk abstain on a surface it can know.
|
||||
// (`currentcolor` is NOT here — it is real paint in the element's own text
|
||||
// color; resolveBackgroundInfo substitutes the computed color for it.)
|
||||
function isNoPaintColorValue(value) {
|
||||
const v = String(value || '').trim().toLowerCase();
|
||||
if (!v) return true;
|
||||
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
|
||||
}
|
||||
|
||||
export {
|
||||
isNeutralColor,
|
||||
parseRgb,
|
||||
relativeLuminance,
|
||||
contrastRatio,
|
||||
parseGradientColors,
|
||||
extractColorFunctionTokens,
|
||||
hasChroma,
|
||||
getHue,
|
||||
colorToHex,
|
||||
oklabToRgb,
|
||||
oklchToRgb,
|
||||
labToRgb,
|
||||
lchToRgb,
|
||||
colorFunctionToRgb,
|
||||
hslToRgb,
|
||||
hwbToRgb,
|
||||
CSS_NAMED_COLORS,
|
||||
splitTopLevelCommas,
|
||||
parseColorMix,
|
||||
parseAnyColor,
|
||||
compositeColorOver,
|
||||
isNoPaintColorValue,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* node hook-admin.mjs off # set enabled: false
|
||||
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
|
||||
* node hook-admin.mjs ignore-rule overused-font --all-values
|
||||
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
|
||||
* node hook-admin.mjs ignore-file <glob> [--shared|--local] # append to ignoreFiles
|
||||
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
||||
* node hook-admin.mjs ignore-value <rule> <value> --local
|
||||
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
|
||||
@@ -166,7 +166,7 @@ function readRawConfigFile(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
|
||||
|
||||
function hookSection(unified) {
|
||||
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
|
||||
@@ -200,6 +200,15 @@ function stripDetectorKeys(raw) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickDetectorKeys(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Write hook runtime config under `hook`, leaving detector filters in
|
||||
// `detector` and preserving sibling keys such as updateCheck.
|
||||
function writeHookConfig(cwd, hookConfig, opts = {}) {
|
||||
@@ -207,10 +216,19 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
|
||||
if (opts.local) ensureHookGitExcludes(cwd);
|
||||
const existingRaw = readRawConfigFile(filePath).raw;
|
||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||
const existingHook = stripDetectorKeys(hookSection(existing));
|
||||
const existingHookSection = hookSection(existing);
|
||||
const existingHook = stripDetectorKeys(existingHookSection);
|
||||
const legacyDetector = pickDetectorKeys(existingHookSection);
|
||||
// Merge over the existing hook object so fields the merge helpers don't manage
|
||||
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
|
||||
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
||||
if (Object.keys(legacyDetector).length > 0) {
|
||||
const existingDetector = detectorSection(existing) || {};
|
||||
next.detector = {
|
||||
...existingDetector,
|
||||
...mergeDetectorConfig(existingDetector, mergeDetectorConfig(legacyDetector)),
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
||||
return filePath;
|
||||
@@ -222,10 +240,14 @@ function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
|
||||
const existingRaw = readRawConfigFile(filePath).raw;
|
||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||
const nextHook = stripDetectorKeys(hookSection(existing));
|
||||
const existingDetector = mergeDetectorConfig(detectorSection(existing));
|
||||
const existingDetectorSection = detectorSection(existing) || {};
|
||||
const existingDetector = mergeDetectorConfig(existingDetectorSection);
|
||||
const next = {
|
||||
...existing,
|
||||
detector: mergeDetectorConfig(detectorConfig, existingDetector),
|
||||
detector: {
|
||||
...existingDetectorSection,
|
||||
...mergeDetectorConfig(detectorConfig, existingDetector),
|
||||
},
|
||||
};
|
||||
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
|
||||
else delete next.hook;
|
||||
@@ -259,12 +281,18 @@ function mergeDetectorConfig(existing, seed = null) {
|
||||
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
|
||||
out.designSystem = { ...seed.designSystem };
|
||||
}
|
||||
if (seed?.advisoryRules === 'include' || seed?.advisoryRules === 'exclude') {
|
||||
out.advisoryRules = seed.advisoryRules;
|
||||
}
|
||||
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
|
||||
out.designSystem = {
|
||||
...(out.designSystem || {}),
|
||||
enabled: base.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
if (base.advisoryRules === 'include' || base.advisoryRules === 'exclude') {
|
||||
out.advisoryRules = base.advisoryRules;
|
||||
}
|
||||
if (Array.isArray(base.ignoreRules)) {
|
||||
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
|
||||
}
|
||||
@@ -558,12 +586,44 @@ function addIgnoreRule(cwd, args) {
|
||||
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
|
||||
}
|
||||
|
||||
function addIgnoreFile(cwd, glob) {
|
||||
function parseIgnoreFileArgs(args) {
|
||||
const positionals = [];
|
||||
let shared = false;
|
||||
let local = false;
|
||||
|
||||
for (const raw of args) {
|
||||
const arg = String(raw || '');
|
||||
if (arg === '--shared') {
|
||||
shared = true;
|
||||
} else if (arg === '--local') {
|
||||
local = true;
|
||||
} else if (arg === '--reason' || arg.startsWith('--reason=')) {
|
||||
throw new Error('--reason is not supported for ignore-file because detector.ignoreFiles stores globs only; use ignore-value when a documented rule-specific exception fits');
|
||||
} else if (arg.startsWith('--')) {
|
||||
throw new Error(`Unknown ignore-file flag: ${arg}`);
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
if (shared && local) throw new Error('Pass only one scope flag: --shared or --local');
|
||||
if (positionals.length > 1) throw new Error('Pass exactly one glob to ignore-file');
|
||||
|
||||
return {
|
||||
glob: positionals[0],
|
||||
local,
|
||||
};
|
||||
}
|
||||
|
||||
function addIgnoreFile(cwd, args) {
|
||||
const parsed = parseIgnoreFileArgs(args);
|
||||
const glob = parsed.glob;
|
||||
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local: parsed.local }));
|
||||
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
||||
writeDetectorConfig(cwd, config);
|
||||
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
|
||||
const target = writeDetectorConfig(cwd, config, { local: parsed.local });
|
||||
const scope = parsed.local ? 'local detector.ignoreFiles' : 'shared detector.ignoreFiles';
|
||||
return `Added "${glob}" to ${scope} (${path.relative(cwd, target) || target}). Current: ${config.ignoreFiles.join(', ')}`;
|
||||
}
|
||||
|
||||
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
|
||||
@@ -727,7 +787,7 @@ function main() {
|
||||
case 'on': out = setEnabled(cwd, true); break;
|
||||
case 'off': out = setEnabled(cwd, false); break;
|
||||
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
|
||||
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
|
||||
case 'ignore-file': out = addIgnoreFile(cwd, rest); break;
|
||||
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
|
||||
case 'reset': out = reset(cwd); break;
|
||||
}
|
||||
|
||||
@@ -16,13 +16,18 @@ import path from 'node:path';
|
||||
|
||||
import {
|
||||
ALLOWED_EXTS,
|
||||
DEFAULT_CONFIG,
|
||||
EDIT_COUNT_THRESHOLD,
|
||||
GENERATED_PATH,
|
||||
SENSITIVE_PATH,
|
||||
appendDesignSystemNote,
|
||||
appendDesignSystemNoteOnce,
|
||||
commitFooterShown,
|
||||
designNoteReserve,
|
||||
designSystemOptions,
|
||||
footerModeForSession,
|
||||
filterFindings,
|
||||
isNativePlatform,
|
||||
isScanTargetInsideProject,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
@@ -161,7 +166,7 @@ function replaceOnce(original, oldString, newString) {
|
||||
}
|
||||
|
||||
function readExistingProjectFile(filePath, cwd) {
|
||||
if (!isInsideProject(filePath, cwd)) return null;
|
||||
if (!isScanTargetInsideProject(filePath, cwd)) return null;
|
||||
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
@@ -232,7 +237,7 @@ function shellCopiedFileContent(command, cwd) {
|
||||
const source = shellCopyPaths(command)?.source;
|
||||
if (!source) return '';
|
||||
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
|
||||
if (!isInsideProject(sourcePath, cwd)) return '';
|
||||
if (!isScanTargetInsideProject(sourcePath, cwd)) return '';
|
||||
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
|
||||
try {
|
||||
const stat = fs.statSync(sourcePath);
|
||||
@@ -328,15 +333,6 @@ function relativePath(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
function isInsideProject(filePath, cwd) {
|
||||
try {
|
||||
const rel = path.relative(cwd, filePath);
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
@@ -353,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
// Cursor caps deny messages around 4000 chars. The cap feeds through the
|
||||
// renderer's clamp, which preserves the policy footer, rather than tail-
|
||||
// slicing the rendered text, which cut the footer off any message the
|
||||
// default 8000-char budget let past 4000.
|
||||
const CURSOR_DENY_LIMIT = 4000;
|
||||
const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. ';
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) {
|
||||
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
||||
// Charge the prefix via reserveChars, not by subtracting from maxChars:
|
||||
// renderTemplate's 500-char floor re-raises any maxChars pushed below it,
|
||||
// un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508).
|
||||
// reserveChars comes off after the floor, so the prefix is charged at every
|
||||
// config tier and the final prefixed message plus a pending staleness note
|
||||
// fits the binding limit. Default-config output is byte-identical.
|
||||
const budget = Math.min(
|
||||
limits.maxChars || DEFAULT_CONFIG.limits.maxChars,
|
||||
CURSOR_DENY_LIMIT,
|
||||
);
|
||||
const rendered = renderTemplate(findings, filePath,
|
||||
{ ...config, limits: { ...limits, maxChars: budget } },
|
||||
{ cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length });
|
||||
return rendered.replace(
|
||||
'[impeccable@1] Design hook findings requiring review',
|
||||
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
|
||||
`[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`,
|
||||
);
|
||||
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
|
||||
}
|
||||
|
||||
function findingSignature(findings) {
|
||||
@@ -414,7 +429,7 @@ async function main() {
|
||||
};
|
||||
|
||||
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
|
||||
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
||||
if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
@@ -476,9 +491,16 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions);
|
||||
const sessionId = event.session_id || event.conversation_id || 'unknown';
|
||||
const cache = readCache(cwd);
|
||||
// Repeated denials for the same session repeat the findings, not the
|
||||
// policy: the full footer emits once per session, the short form after.
|
||||
const footerMode = footerModeForSession(cache, sessionId);
|
||||
const message = appendDesignSystemNoteOnce(
|
||||
cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)),
|
||||
scanOptions, cache, sessionId, config,
|
||||
);
|
||||
commitFooterShown(cache, sessionId, message);
|
||||
const denial = bumpCursorDenial(cache, sessionId, filePath, filtered);
|
||||
persistCache(cwd, cache);
|
||||
if (denial.count > EDIT_COUNT_THRESHOLD) {
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
||||
* renderTemplate(findings, filePath, config, opts)
|
||||
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
|
||||
* appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config)
|
||||
* designNoteReserve(scanOptions, cache, sessionId)
|
||||
* footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text)
|
||||
* shouldEmitAckForFile(filePath, config?)
|
||||
* writeAuditLog(env, entry)
|
||||
* loadDetector() -> Promise<{ detectText, detectHtml }>
|
||||
@@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return '';
|
||||
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
||||
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
||||
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
|
||||
// reserveChars holds back room for a note the caller appends after render
|
||||
// (the DESIGN.md staleness note), so the final payload stays inside the
|
||||
// configured budget. It comes off after the 500-char floor, so at floor
|
||||
// configs the note keeps guaranteed delivery room; the clamp budget can
|
||||
// therefore sit below 500, which clampLastLine's footer-preserving
|
||||
// fallback handles (Bugbot on PR #508).
|
||||
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0);
|
||||
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const display = relativize(filePath, cwd);
|
||||
@@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
||||
const remaining = total - shown.length;
|
||||
|
||||
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
|
||||
const lines = shown.map((f) => formatFindingLine(f));
|
||||
const seenRules = new Set();
|
||||
const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules));
|
||||
const more = remaining > 0
|
||||
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
|
||||
: null;
|
||||
const footer = directiveFooter(display);
|
||||
const footer = directiveFooter({ mode: opts.footer });
|
||||
|
||||
const blocks = [header, ...lines];
|
||||
if (more) blocks.push(more);
|
||||
@@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
|
||||
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
||||
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
||||
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
|
||||
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0);
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
|
||||
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
|
||||
const lines = [];
|
||||
let shownCount = 0;
|
||||
// One seen-set across all groups: a rule already described under one file
|
||||
// is not re-described under the next.
|
||||
const seenRules = new Set();
|
||||
|
||||
for (const group of realGroups) {
|
||||
const display = relativize(group.filePath, cwd);
|
||||
@@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
const remainingCap = Math.max(0, cap - shownCount);
|
||||
const shown = group.findings.slice(0, remainingCap);
|
||||
for (const finding of shown) {
|
||||
lines.push(formatFindingLine(finding));
|
||||
lines.push(formatDedupedFindingLine(finding, seenRules));
|
||||
}
|
||||
shownCount += shown.length;
|
||||
const hidden = group.findings.length - shown.length;
|
||||
@@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const footer = directiveFooter('the affected files', { grouped: true });
|
||||
const footer = directiveFooter({ mode: opts.footer });
|
||||
let text = [header, ...lines, '', footer].join('\n');
|
||||
if (text.length > maxChars) {
|
||||
text = clampGroupedToBudget(header, lines, footer, maxChars);
|
||||
@@ -1037,82 +1050,149 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// The clamp contract, shared by both budget functions: the footer is policy,
|
||||
// not detail, so it survives every clamp. Try the requested footer first;
|
||||
// when it cannot fit even after dropping finding lines, retry with the short
|
||||
// policy rather than sacrifice findings that fit beside it. A result that
|
||||
// dropped every finding line (a grouped render can fit a bare file header)
|
||||
// does not count as a fit: findings are why the emission exists.
|
||||
const isFindingLine = (line) => line.startsWith('- ');
|
||||
|
||||
function footerFallbacks(footer) {
|
||||
const short = directiveFooter({ mode: 'short' });
|
||||
return footer === short ? [footer] : [footer, short];
|
||||
}
|
||||
|
||||
function clampGroupedToBudget(header, lines, footer, maxChars) {
|
||||
const assemble = (linesArr, omitted) => [
|
||||
const assemble = (linesArr, omitted, footerText) => [
|
||||
header,
|
||||
...linesArr,
|
||||
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
|
||||
'',
|
||||
footer,
|
||||
footerText,
|
||||
].join('\n');
|
||||
|
||||
let working = lines.slice();
|
||||
let omitted = false;
|
||||
let assembled = assemble(working, omitted);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
omitted = true;
|
||||
assembled = assemble(working, omitted);
|
||||
for (const footerText of footerFallbacks(footer)) {
|
||||
let working = lines.slice();
|
||||
let omitted = false;
|
||||
let assembled = assemble(working, omitted, footerText);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
omitted = true;
|
||||
assembled = assemble(working, omitted, footerText);
|
||||
}
|
||||
if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled;
|
||||
}
|
||||
if (assembled.length > maxChars) {
|
||||
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
||||
}
|
||||
return assembled;
|
||||
return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText),
|
||||
lines.find(isFindingLine) || lines[0], maxChars);
|
||||
}
|
||||
|
||||
function clampToBudget(header, lines, more, footer, maxChars) {
|
||||
const assemble = (linesArr, moreText) => {
|
||||
const assemble = (linesArr, moreText, footerText) => {
|
||||
const blocks = [header, ...linesArr];
|
||||
if (moreText) blocks.push(moreText);
|
||||
blocks.push('');
|
||||
blocks.push(footer);
|
||||
blocks.push(footerText);
|
||||
return blocks.join('\n');
|
||||
};
|
||||
|
||||
let working = lines.slice();
|
||||
let moreText = more;
|
||||
let assembled = assemble(working, moreText);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
|
||||
assembled = assemble(working, moreText);
|
||||
let lastMore = more;
|
||||
for (const footerText of footerFallbacks(footer)) {
|
||||
let working = lines.slice();
|
||||
let moreText = more;
|
||||
let assembled = assemble(working, moreText, footerText);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
|
||||
assembled = assemble(working, moreText, footerText);
|
||||
}
|
||||
lastMore = moreText;
|
||||
if (assembled.length <= maxChars) return assembled;
|
||||
}
|
||||
if (assembled.length > maxChars) {
|
||||
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
||||
}
|
||||
return assembled;
|
||||
return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText),
|
||||
lines.find(isFindingLine) || lines[0], maxChars);
|
||||
}
|
||||
|
||||
function formatFindingLine(f) {
|
||||
// Last resort with one finding line left: the short policy gets the budget
|
||||
// first, the line is clipped to what remains. The pre-fix tail-slice cut
|
||||
// whatever happened to be last, which was always the footer.
|
||||
function clampLastLine(build, line, maxChars) {
|
||||
const footerText = directiveFooter({ mode: 'short' });
|
||||
const bare = build([], footerText);
|
||||
// +1 for the newline the line itself brings when it joins the blocks.
|
||||
const room = maxChars - bare.length - 1;
|
||||
if (room >= 24) {
|
||||
const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line;
|
||||
return build([clipped], footerText);
|
||||
}
|
||||
// No room for even a clipped finding line: the note reservation can pull
|
||||
// the budget below the 500-char floor, and a deep file path can push the
|
||||
// header past what remains beside the short policy (Bugbot on PR #508).
|
||||
// Drop the line, and if the bare header + policy still overflow, clip the
|
||||
// head. Never tail-slice: the footer sits at the end, so a tail slice is
|
||||
// exactly the footer cut this renderer exists to prevent.
|
||||
if (bare.length <= maxChars) return bare;
|
||||
const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4));
|
||||
return `${head}…\n\n${footerText}`;
|
||||
}
|
||||
|
||||
// `compact` drops the registry description: within one emission the first
|
||||
// occurrence of a rule carries the full description and repeats keep only the
|
||||
// rule id, name, and their own ignore hint (values differ per line, so the
|
||||
// hint must survive the dedupe).
|
||||
function formatFindingLine(f, opts = {}) {
|
||||
const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-';
|
||||
const desc = (f.description || '').trim();
|
||||
const desc = opts.compact ? '' : (f.description || '').trim();
|
||||
const name = (f.name || '').trim();
|
||||
// Description from the registry already ends in punctuation; join with a
|
||||
// single space. `name` may have a trailing period already, keep it clean.
|
||||
const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
|
||||
const ignoreCommand = formatFindingIgnoreCommand(f);
|
||||
const ignoreSegment = ignoreCommand
|
||||
? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`
|
||||
: '';
|
||||
const ignoreHint = formatFindingIgnoreHint(f);
|
||||
const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : '';
|
||||
return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function formatFindingIgnoreCommand(finding) {
|
||||
// Dedupe applied in shown-line order, so the first rendered occurrence of a
|
||||
// rule always carries the description. The budget clamps pop lines from the
|
||||
// end, which can never orphan a compact repeat before its described first
|
||||
// occurrence.
|
||||
function formatDedupedFindingLine(finding, seenRules) {
|
||||
const rule = normalizeIgnoreRule(finding?.antipattern);
|
||||
const compact = rule ? seenRules.has(rule) : false;
|
||||
if (rule) seenRules.add(rule);
|
||||
return formatFindingLine(finding, { compact });
|
||||
}
|
||||
|
||||
// The rule/value pair the footer's `hook-admin.mjs ignore-value` command
|
||||
// takes. Deliberately just the args: the executable prefix, the --reason
|
||||
// contract, and the disclosure rule live in the directive footer, stated once
|
||||
// instead of per line.
|
||||
function formatFindingIgnoreHint(finding) {
|
||||
if (!finding || typeof finding !== 'object') return '';
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
if (!rule) return '';
|
||||
const normalizedValue = extractFindingIgnoreValue(finding);
|
||||
if (!normalizedValue) return '';
|
||||
const value = extractFindingIgnoreValueRaw(finding);
|
||||
const valueArg = quoteCommandArg(value);
|
||||
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
|
||||
return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
||||
const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding));
|
||||
return `ignore-value ${rule} ${valueArg}`;
|
||||
}
|
||||
|
||||
function quoteCommandArg(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
|
||||
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
// The suggestion is meant to be run on this same machine, so quote for its
|
||||
// shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside
|
||||
// double quotes, and these values come from scanned file content (a
|
||||
// font-family name) or a file path, so untrusted input must be
|
||||
// single-quoted (issue #476). Windows cmd.exe performs no such command
|
||||
// substitution, but it treats a single quote as a literal character rather
|
||||
// than a grouping delimiter, so a value or path containing spaces has to
|
||||
// stay double-quoted there (Greptile #533). Keep the pre-existing
|
||||
// double-quote escaping on Windows so that path's behavior is unchanged.
|
||||
if (process.platform === 'win32') {
|
||||
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
return `'${text.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function relativize(filePath, cwd) {
|
||||
@@ -1335,6 +1415,51 @@ function isInsideProject(filePath, projectCwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a path to its canonical (symlink-free) form. When the path does
|
||||
// not exist yet — the before-edit hook gates proposed Writes — canonicalize
|
||||
// the nearest existing ancestor and re-append the remainder, so a new file
|
||||
// under a symlinked root still compares equal to its canonical project.
|
||||
// Memoized: the hook runs as a fresh process per tool event, so the cache
|
||||
// amounts to once-per-event work — the scan loops re-check the same project
|
||||
// root for every target file. The cap only matters to long-lived importers
|
||||
// like the test runner.
|
||||
const canonicalPathCache = new Map();
|
||||
const CANONICAL_PATH_CACHE_MAX = 1024;
|
||||
|
||||
function canonicalPath(p) {
|
||||
const resolved = path.resolve(p);
|
||||
if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved);
|
||||
let canonical = resolved;
|
||||
let dir = resolved;
|
||||
const tail = [];
|
||||
while (true) {
|
||||
try {
|
||||
canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir);
|
||||
break;
|
||||
} catch { /* keep climbing */ }
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
tail.unshift(path.basename(dir));
|
||||
dir = parent;
|
||||
}
|
||||
if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear();
|
||||
canonicalPathCache.set(resolved, canonical);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
// Containment gate shared by the before-edit hook and both scan passes. A
|
||||
// session routinely touches files that belong to no project or to a
|
||||
// different one — harness scratchpad dirs under the system temp root,
|
||||
// sibling checkouts, one-off throwaway HTML — and findings against those are
|
||||
// judged with THIS project's config and DESIGN.md palette, which is never
|
||||
// right. Skip them (audit reason: outside-project). Paths are canonicalized
|
||||
// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the
|
||||
// comparison.
|
||||
export function isScanTargetInsideProject(filePath, projectCwd) {
|
||||
if (!filePath || !projectCwd) return false;
|
||||
return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd));
|
||||
}
|
||||
|
||||
export function parseStaticStyleImports(content, fromFile, projectCwd) {
|
||||
if (!content || typeof content !== 'string') return [];
|
||||
const dir = path.dirname(fromFile);
|
||||
@@ -1549,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) {
|
||||
}
|
||||
}
|
||||
|
||||
const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
|
||||
|
||||
export function appendDesignSystemNote(text, scanOptions) {
|
||||
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
||||
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
|
||||
return `${text}\n\n${DESIGN_STALE_NOTE}`;
|
||||
}
|
||||
|
||||
// Session-scoped once-only gate for repeat-prone message parts. Returns true
|
||||
// the first time a flag is consumed in a session and false after, mirroring
|
||||
// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not
|
||||
// change between edits, so re-stating them on every emission spends context
|
||||
// to say nothing new. Callers must persist the cache for the flag to stick.
|
||||
function consumeSessionNoticeFlag(cache, sessionId, flag) {
|
||||
const session = ensureSession(cache, sessionId);
|
||||
if (session[flag]) return false;
|
||||
session[flag] = true;
|
||||
session.updatedAt = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Once-per-session variant of appendDesignSystemNote for the emission paths
|
||||
// that have cache access. The staleness note names standing project state,
|
||||
// not new information, so one mention per session is enough. The note is
|
||||
// appended after the renderer has clamped to the configured budget: render
|
||||
// paths reserve room for it via designNoteReserve, and the size check here
|
||||
// is the safety net for the ack paths, deferring (without consuming the
|
||||
// flag) to a later emission rather than busting maxChars.
|
||||
export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) {
|
||||
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
||||
const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars);
|
||||
if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text;
|
||||
if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text;
|
||||
return appendDesignSystemNote(text, scanOptions);
|
||||
}
|
||||
|
||||
// Render-time reservation for the note above: how many characters the
|
||||
// renderer must hold back so a pending staleness note still fits inside the
|
||||
// configured budget. Zero once the session has seen the note. Without the
|
||||
// reservation, a session whose every emission fills the budget would defer
|
||||
// the note forever.
|
||||
export function designNoteReserve(scanOptions, cache, sessionId) {
|
||||
if (!scanOptions?.designSystem?.mdNewerThanJson) return 0;
|
||||
if (ensureSession(cache, sessionId).designNoteShown) return 0;
|
||||
return DESIGN_STALE_NOTE.length + 2;
|
||||
}
|
||||
|
||||
// Full directive footer once per session, the short reminder after. Fresh
|
||||
// emissions and Cursor denials share the session flag (`footerShown`), so a
|
||||
// session pays the full policy exactly once however it first fires. The mode
|
||||
// is a peek: the clamp can downgrade a requested full footer under a tight
|
||||
// budget, so the flag commits only when the complete full policy actually
|
||||
// reached the output. Matching the whole footer text (not a sentinel) keeps
|
||||
// the flag honest against any truncation that spares the opening words.
|
||||
export function footerModeForSession(cache, sessionId) {
|
||||
return ensureSession(cache, sessionId).footerShown ? 'short' : 'full';
|
||||
}
|
||||
|
||||
export function commitFooterShown(cache, sessionId, text) {
|
||||
if (!text || !text.includes(directiveFooter())) return;
|
||||
const session = ensureSession(cache, sessionId);
|
||||
if (session.footerShown) return;
|
||||
session.footerShown = true;
|
||||
session.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`;
|
||||
|
||||
// The directive footer is the part of the hook output that steers model
|
||||
// behavior. Three intentional moves:
|
||||
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
|
||||
// revising..." which the model treats as a soft suggestion it can
|
||||
// override when the user asked for any kind of throwaway / demo UI.
|
||||
// 2. **Explicit judgment clause.** Without it, the model will try to
|
||||
// "fix" intentional motion, bad fixtures, anti-pattern examples in
|
||||
// docs, or test cases. Naming the judgment inline beats hoping the
|
||||
// model infers it from context.
|
||||
// 3. **Acknowledgement instruction.** Hook output is injected as
|
||||
// developer-role context, not a chat turn, so the user never sees the
|
||||
// raw envelope. Asking the model to surface the resolution in its
|
||||
// reply is the cheapest way to make the feedback loop visible.
|
||||
function directiveFooter(display, opts = {}) {
|
||||
// Offer the rule-scoped-to-file form first. `ignore-file` silences every rule
|
||||
// for the path forever, which is far more than one noisy rule on a real UI
|
||||
// surface justifies, and it was previously the only option named here.
|
||||
const target = opts.grouped ? '<path>' : quoteCommandArg(display);
|
||||
const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value <id> "*" --file ${target}\` to scope just that rule to the file, or \`${IMPECCABLE_COMMAND} hooks ignore-file ${target}\` only when the whole file is out of scope for design review (a fixture, a generated artifact, a deliberate demo)`;
|
||||
// behavior. Intentional moves, in order:
|
||||
// 1. **Imperative, not advisory.** "Triage each finding..." beats
|
||||
// "Consider revising...", which the model treats as a soft suggestion.
|
||||
// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The
|
||||
// suppress branch names the calibration examples (demo, fixture,
|
||||
// documented bad design, user-confirmed choice) because the agent now
|
||||
// acts on its own confidence and needs the bar stated.
|
||||
// 3. **Executable ignore path.** The old footer named only the slash
|
||||
// command, which an agent reacting to hook output cannot run; the
|
||||
// hook-admin.mjs invocation is runnable as-is and keeps agents out of
|
||||
// hand-editing config.json.
|
||||
// 4. **Honest provenance.** The --reason is the audit trail; "user
|
||||
// confirmed" appears only when the user actually did.
|
||||
// 5. **Acknowledgement instruction.** Hook output is injected as
|
||||
// developer-role context, so the reply is where the user sees the
|
||||
// resolution, including any ignore the agent persisted.
|
||||
// 6. **Once per session.** The full policy emits on the session's first
|
||||
// fire; later emissions carry the one-line short form (mode 'short').
|
||||
function directiveFooter(opts = {}) {
|
||||
if (opts.mode === 'short') {
|
||||
// No command path here: the session's first emission already gave the
|
||||
// runnable hook-admin.mjs invocation, and restating ~70 chars of absolute
|
||||
// path on every repeat is the duplication this mode exists to cut.
|
||||
return 'Triage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `hook-admin.mjs ignore-value` and disclose them in your reply; unsure, ask in one line.';
|
||||
}
|
||||
return [
|
||||
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
|
||||
'',
|
||||
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
|
||||
'',
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For a finding whose line shows no exact ignore-value command, such as \`side-tab\`, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
|
||||
'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:',
|
||||
'- Real design problem: fix it. Keep intentional design as designed.',
|
||||
`- Confident false positive or sanctioned exception (an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion, a choice the user confirmed): persist the narrowest ignore yourself and disclose it. Run \`${HOOK_ADMIN_COMMAND} ignore-value <rule> "<value>" --reason "<who decided: evidence>"\` with the pair shown on the finding line, or value "*" plus \`--file <path>\` when the line shows none. Write "user confirmed" in a reason only when the user did.`,
|
||||
'- Unsure: leave it as is and ask the user in one line.',
|
||||
`Self-serve ends at ignore-value: \`ignore-file\` and \`ignore-rule\` need the user's explicit approval, and never add an ignore to push a blocked write through. Full suppression ladder: ${IMPECCABLE_COMMAND} hooks.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -1693,6 +1887,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
lastSkip = 'file-missing';
|
||||
continue;
|
||||
}
|
||||
if (!isScanTargetInsideProject(filePath, projectCwd)) {
|
||||
lastSkip = 'outside-project';
|
||||
continue;
|
||||
}
|
||||
|
||||
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
|
||||
if (maxFileBytes > 0) {
|
||||
@@ -1796,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
}
|
||||
}
|
||||
|
||||
// Persist only when the write is earned: fresh findings justify creating
|
||||
// `.impeccable/` (dedup and suppression need it), deferred findings do
|
||||
// too (the Stop deep pass needs the touched-file list to surface them),
|
||||
// and an already-present `.impeccable/` dir marks a project that opted
|
||||
// in. A non-UI edit, or a clean UI edit in a project with no Impeccable
|
||||
// footprint, must be a no-op on disk (issues #344, #305).
|
||||
if (freshGroups.length > 0 || deferredTotal > 0
|
||||
|| (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
|
||||
persistCache(projectCwd, cache);
|
||||
}
|
||||
|
||||
// The session notice flags mutate the cache, so they must settle before
|
||||
// the persist that makes them stick across events.
|
||||
if (freshGroups.length > 0) {
|
||||
const firstGroup = freshGroups[0];
|
||||
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
|
||||
const footerMode = footerModeForSession(cache, sessionId);
|
||||
const text = appendDesignSystemNoteOnce(
|
||||
renderGroupedTemplate(freshGroups, config, {
|
||||
cwd: projectCwd,
|
||||
footer: footerMode,
|
||||
reserveChars: designNoteReserve(scanOptions, cache, sessionId),
|
||||
}),
|
||||
scanOptions, cache, sessionId, config,
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
// Fresh findings always earn the cache write, including creating
|
||||
// `.impeccable/`: dedup, suppression, and the notice flags need it.
|
||||
persistCache(projectCwd, cache);
|
||||
const allFindings = freshGroups.flatMap((group) => group.findings);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -1832,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the ack emission before the persist below: appendDesignSystem-
|
||||
// NoteOnce consumes a session flag, and the flag only sticks when the
|
||||
// write happens after it. Quiet mode emits nothing, so it consumes
|
||||
// nothing. The clean arm mirrors the branch order further down: pending
|
||||
// outranks suppression, suppression outranks clean.
|
||||
let ack = null;
|
||||
if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
|
||||
ack = {
|
||||
kind: 'pending',
|
||||
text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config),
|
||||
};
|
||||
} else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
|
||||
ack = {
|
||||
kind: 'clean',
|
||||
text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config),
|
||||
};
|
||||
}
|
||||
|
||||
// Persist only when the write is earned: deferred findings need the
|
||||
// touched-file list for the Stop deep pass, and an already-present
|
||||
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
|
||||
// clean UI edit in a project with no Impeccable footprint, must be a
|
||||
// no-op on disk (issues #344, #305).
|
||||
if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
|
||||
persistCache(projectCwd, cache);
|
||||
}
|
||||
|
||||
if (detectorThrewAny && !pendingWinner && !cleanWinner) {
|
||||
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
@@ -1840,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
|
||||
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
|
||||
if (ack?.kind === 'pending') {
|
||||
const text = ack.text;
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: payload(text, 'PostToolUse', harness),
|
||||
@@ -1874,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
};
|
||||
}
|
||||
|
||||
if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
|
||||
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
|
||||
if (ack?.kind === 'clean') {
|
||||
const text = ack.text;
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: payload(text, 'PostToolUse', harness),
|
||||
@@ -2023,6 +2251,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
const relForMatch = relativize(filePath, projectCwd);
|
||||
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
// Caches written before this gate existed can still hold out-of-project
|
||||
// paths, so the Stop pass re-checks containment rather than trusting
|
||||
// the per-edit pass to have filtered them.
|
||||
if (!isScanTargetInsideProject(filePath, projectCwd)) continue;
|
||||
|
||||
scanned += 1;
|
||||
let content = '';
|
||||
@@ -2055,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear.
|
||||
persistCache(projectCwd, cache);
|
||||
// A per-edit fire earlier in this session already consumed the footer
|
||||
// flag, so the Stop wall of text carries the one-line short footer.
|
||||
const footerMode = footerModeForSession(cache, sessionId);
|
||||
const text = appendDesignSystemNoteOnce(
|
||||
renderGroupedTemplate(freshGroups, config, {
|
||||
cwd: projectCwd,
|
||||
footer: footerMode,
|
||||
reserveChars: designNoteReserve(scanOptions, cache, sessionId),
|
||||
}),
|
||||
scanOptions, cache, sessionId, config,
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: payload(text, 'Stop', harness),
|
||||
|
||||
@@ -50,9 +50,36 @@ export function normalizeConceptForm(value) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function validateConceptEntry(concept, { existingForms = new Map() } = {}) {
|
||||
export function validateConceptEntry(concept, { existingForms = new Map(), axes = null } = {}) {
|
||||
const errors = [];
|
||||
const id = concept?.id || '(unknown)';
|
||||
|
||||
// Recorded aesthetic axis values. Optional, and absent means the value is
|
||||
// inferred from the system rules instead. Some axes cannot be inferred at all:
|
||||
// depth's keyword probe matched worlds that said "no cast shadow anywhere",
|
||||
// and motion and colour strategy describe properties the rules never state, so
|
||||
// a wave that assigns those has to record them or the assignment is lost.
|
||||
// Validated against the axes definition when the caller supplies it, because a
|
||||
// typo would read as "unrecorded" and silently fall back to a probe that is
|
||||
// known not to work.
|
||||
if (concept?.axes !== undefined && concept.axes !== null) {
|
||||
if (typeof concept.axes !== 'object' || Array.isArray(concept.axes)) {
|
||||
errors.push(`concept ${id} axes must be an object of axis id to value id`);
|
||||
} else if (axes) {
|
||||
const byId = new Map((axes.axes || []).map(axis => [axis.id, axis]));
|
||||
for (const [axisId, valueId] of Object.entries(concept.axes)) {
|
||||
const axis = byId.get(axisId);
|
||||
if (!axis) {
|
||||
errors.push(`concept ${id} names unknown axis "${axisId}"`);
|
||||
} else if (!(axis.values || []).some(value => value.id === valueId)) {
|
||||
errors.push(
|
||||
`concept ${id} axis "${axisId}" has unknown value "${valueId}" `
|
||||
+ `(expected one of ${(axis.values || []).map(v => v.id).join(', ')})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
|
||||
errors.push(`invalid concept id: ${String(concept?.id)}`);
|
||||
}
|
||||
@@ -82,6 +109,18 @@ export function validateConceptEntry(concept, { existingForms = new Map() } = {}
|
||||
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||
errors.push(`concept ${id} must have exactly three structural tags`);
|
||||
}
|
||||
// The slop this world in particular is at risk of. Optional, because 541
|
||||
// entries predate it and none of them are wrong for lacking it. A world built
|
||||
// from posters is at risk of shouting and one built from instruments is at
|
||||
// risk of dead greys; a global detector cannot know which, and the author can.
|
||||
if (concept?.avoid !== undefined) {
|
||||
if (!Array.isArray(concept.avoid)
|
||||
|| concept.avoid.length < 2
|
||||
|| concept.avoid.length > 3
|
||||
|| concept.avoid.some(item => typeof item !== 'string' || item.trim().length < 12 || item.trim().length > 160)) {
|
||||
errors.push(`concept ${id} avoid must be two or three negations of 12–160 characters`);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(concept?.system)
|
||||
|| concept.system.length !== SYSTEM_PREFIXES.length
|
||||
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
||||
|
||||
@@ -2,15 +2,20 @@
|
||||
// the live-mode design-system panel can render. Deterministic, dependency-free.
|
||||
//
|
||||
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
|
||||
// (prose with six canonical H2 sections). When frontmatter is present, it's
|
||||
// (prose with eight canonical H2 sections). When frontmatter is present, it's
|
||||
// exposed on `model.frontmatter` alongside the prose-scraped sections;
|
||||
// consumers can prefer frontmatter values and fall back to prose.
|
||||
|
||||
// Array order is also match precedence: matchCanonicalSection's keyword-contained
|
||||
// pass returns the first entry a heading contains, so reordering this changes
|
||||
// which section an ambiguous heading resolves to.
|
||||
const CANONICAL_SECTIONS = [
|
||||
'Overview',
|
||||
'Colors',
|
||||
'Typography',
|
||||
'Layout',
|
||||
'Elevation',
|
||||
'Shapes',
|
||||
'Components',
|
||||
"Do's and Don'ts",
|
||||
];
|
||||
@@ -115,10 +120,71 @@ function stripInlineYamlComment(s) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// YAML double-quoted scalars process backslash escapes. Stripping the outer
|
||||
// quotes without unescaping leaves them in place, so a nested font family like
|
||||
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
|
||||
// keeps its literal backslashes and never matches the same family in CSS.
|
||||
// The full YAML 1.2 double-quote escape set (spec section 5.7).
|
||||
const YAML_SIMPLE_ESCAPES = {
|
||||
'0': '\0',
|
||||
a: '\x07',
|
||||
b: '\b',
|
||||
t: '\t',
|
||||
n: '\n',
|
||||
v: '\v',
|
||||
f: '\f',
|
||||
r: '\r',
|
||||
e: '\x1b',
|
||||
' ': ' ',
|
||||
'"': '"',
|
||||
'/': '/',
|
||||
'\\': '\\',
|
||||
N: '\u0085',
|
||||
_: '\u00a0',
|
||||
L: '\u2028',
|
||||
P: '\u2029',
|
||||
};
|
||||
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
|
||||
|
||||
function unescapeYamlDoubleQuoted(body) {
|
||||
let out = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch !== '\\' || i === body.length - 1) {
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
const next = body[i + 1];
|
||||
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
|
||||
out += YAML_SIMPLE_ESCAPES[next];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
|
||||
// literal rather than corrupting the rest of the scalar.
|
||||
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
|
||||
if (hexLen) {
|
||||
const hex = body.slice(i + 2, i + 2 + hexLen);
|
||||
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
|
||||
if (codePoint >= 0 && codePoint <= 0x10ffff) {
|
||||
out += String.fromCodePoint(codePoint);
|
||||
i += 1 + hexLen;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseScalar(raw) {
|
||||
const s = raw.trim();
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||
return unescapeYamlDoubleQuoted(s.slice(1, -1));
|
||||
}
|
||||
// Single-quoted YAML escapes only the quote itself, by doubling it.
|
||||
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
|
||||
return s.slice(1, -1).split("''").join("'");
|
||||
}
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
@@ -330,17 +396,16 @@ function extractOverview(section) {
|
||||
if (!section) return null;
|
||||
const text = section.lines.join('\n');
|
||||
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
|
||||
const keyChars = [];
|
||||
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
|
||||
if (keyCharMatch) {
|
||||
for (const line of keyCharMatch[1].split('\n')) {
|
||||
const m = line.match(/^\s*[-*]\s+(.+)$/);
|
||||
if (m) keyChars.push(stripBold(m[1].trim()));
|
||||
}
|
||||
}
|
||||
const keyChars = keyCharMatch
|
||||
? collectBullets(keyCharMatch[1].split('\n')).map((bullet) => stripBold(bullet.trim()))
|
||||
: [];
|
||||
const prose = keyCharMatch
|
||||
? text.slice(0, keyCharMatch.index) + text.slice(keyCharMatch.index + keyCharMatch[0].length)
|
||||
: text;
|
||||
|
||||
// Philosophy paragraphs: everything that isn't a rule header or key-char block
|
||||
const paragraphs = collectParagraphs(section.lines).filter(
|
||||
const paragraphs = collectParagraphs(prose.split('\n')).filter(
|
||||
(p) =>
|
||||
!p.startsWith('**Creative North Star') &&
|
||||
!p.startsWith('**Key Characteristics')
|
||||
@@ -602,11 +667,19 @@ function parseTypeBullet(bullet) {
|
||||
};
|
||||
}
|
||||
|
||||
function extractElevation(section) {
|
||||
function extractGuidance(section) {
|
||||
if (!section) return null;
|
||||
const subs = splitSubsections(section.lines);
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
description: collectParagraphs(subs[0].lines).join(' ') || null,
|
||||
rules: extractNamedRules(section.lines),
|
||||
};
|
||||
}
|
||||
|
||||
const description = collectParagraphs(subs[0].lines).join(' ') || null;
|
||||
function extractElevation(section) {
|
||||
const guidance = extractGuidance(section);
|
||||
if (!guidance) return null;
|
||||
|
||||
const shadows = [];
|
||||
const seen = new Set();
|
||||
@@ -631,12 +704,7 @@ function extractElevation(section) {
|
||||
for (const inline of extractInlineShadows(b)) dedupe(inline);
|
||||
}
|
||||
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
description,
|
||||
shadows,
|
||||
rules: extractNamedRules(section.lines),
|
||||
};
|
||||
return { ...guidance, shadows };
|
||||
}
|
||||
|
||||
function extractInlineShadows(text) {
|
||||
@@ -768,6 +836,15 @@ function extractDosDonts(section) {
|
||||
|
||||
// ---------- Coverage assessment ----------
|
||||
|
||||
// Sections whose model is description-plus-rules only (see extractGuidance).
|
||||
const guidanceCoverage = (guidance) =>
|
||||
guidance
|
||||
? {
|
||||
description: Boolean(guidance.description),
|
||||
rules: guidance.rules.length,
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
function assessCoverage(model) {
|
||||
const report = {};
|
||||
|
||||
@@ -796,6 +873,8 @@ function assessCoverage(model) {
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.layout = guidanceCoverage(model.layout);
|
||||
|
||||
report.elevation = model.elevation
|
||||
? {
|
||||
shadows: model.elevation.shadows.length,
|
||||
@@ -804,6 +883,8 @@ function assessCoverage(model) {
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.shapes = guidanceCoverage(model.shapes);
|
||||
|
||||
report.components = model.components
|
||||
? {
|
||||
count: model.components.components.length,
|
||||
@@ -833,7 +914,9 @@ export function parseDesignMd(md) {
|
||||
overview: extractOverview(sections['Overview']),
|
||||
colors: extractColors(sections['Colors']),
|
||||
typography: extractTypography(sections['Typography']),
|
||||
layout: extractGuidance(sections['Layout']),
|
||||
elevation: extractElevation(sections['Elevation']),
|
||||
shapes: extractGuidance(sections['Shapes']),
|
||||
components: extractComponents(sections['Components']),
|
||||
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
|
||||
};
|
||||
|
||||
@@ -206,10 +206,10 @@ function parseIgnoreColor(value) {
|
||||
if (rgb) {
|
||||
const parts = splitColorArgs(rgb[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const r = parseRgbChannel(parts[0]);
|
||||
const g = parseRgbChannel(parts[1]);
|
||||
const b = parseRgbChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
const r = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.rgb);
|
||||
const g = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.rgb);
|
||||
const b = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.rgb);
|
||||
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
|
||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
@@ -218,10 +218,10 @@ function parseIgnoreColor(value) {
|
||||
if (hsl) {
|
||||
const parts = splitColorArgs(hsl[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const h = parseHueChannel(parts[0]);
|
||||
const s = parsePercentChannel(parts[1]);
|
||||
const l = parsePercentChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
const h = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.hue);
|
||||
const s = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.percent);
|
||||
const l = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.percent);
|
||||
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
|
||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||
return hslToRgb(h, s, l, a);
|
||||
}
|
||||
@@ -230,18 +230,13 @@ function parseIgnoreColor(value) {
|
||||
}
|
||||
|
||||
function parseHexIgnoreColor(hex) {
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
const expanded = hex.length <= 4
|
||||
? [...hex].map((digit) => digit.repeat(2)).join('')
|
||||
: hex;
|
||||
const [r, g, b, alpha = 255] = expanded
|
||||
.match(/../g)
|
||||
.map((channel) => Number.parseInt(channel, 16));
|
||||
return { r, g, b, a: alpha / 255 };
|
||||
}
|
||||
|
||||
function splitColorArgs(body) {
|
||||
@@ -259,47 +254,34 @@ function splitColorArgs(body) {
|
||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||
}
|
||||
|
||||
function parseRgbChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const scaled = match[2] ? value * 2.55 : value;
|
||||
if (scaled < 0 || scaled > 255) return null;
|
||||
return Math.round(scaled);
|
||||
}
|
||||
const CSS_NUMBER_RE = /^(-?\d*\.?\d+)(%|deg|rad|turn|grad)?$/;
|
||||
const identity = (value) => value;
|
||||
const COLOR_CHANNEL_FORMATS = {
|
||||
rgb: { units: { '': identity, '%': (value) => value * 2.55 }, min: 0, max: 255, round: true },
|
||||
alpha: { units: { '': identity, '%': (value) => value / 100 }, min: 0, max: 1 },
|
||||
hue: {
|
||||
units: {
|
||||
'': identity,
|
||||
deg: identity,
|
||||
rad: (value) => value * (180 / Math.PI),
|
||||
turn: (value) => value * 360,
|
||||
grad: (value) => value * 0.9,
|
||||
},
|
||||
},
|
||||
percent: { units: { '%': (value) => value / 100 }, min: 0, max: 1 },
|
||||
};
|
||||
|
||||
function parseAlphaChannel(raw) {
|
||||
function parseColorChannel(raw, { units, min = -Infinity, max = Infinity, round = false }) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
const match = text.match(CSS_NUMBER_RE);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const alpha = match[2] ? value / 100 : value;
|
||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
||||
}
|
||||
|
||||
function parseHueChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const unit = match[2] || 'deg';
|
||||
if (unit === 'turn') return value * 360;
|
||||
if (unit === 'rad') return value * (180 / Math.PI);
|
||||
if (unit === 'grad') return value * 0.9;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePercentChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return value >= 0 && value <= 100 ? value / 100 : null;
|
||||
const convert = units[match[2] || ''];
|
||||
if (!convert) return null;
|
||||
const number = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(number)) return null;
|
||||
const value = convert(number);
|
||||
if (value < min || value > max) return null;
|
||||
return round ? Math.round(value) : value;
|
||||
}
|
||||
|
||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* within the first ~300 characters — catches non-git projects.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) {
|
||||
|
||||
function isGitIgnored(absPath, cwd) {
|
||||
try {
|
||||
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
|
||||
// argv form, never a shell: this runs on every file the live-mode source
|
||||
// walk reaches, so a hostile filename embedding $(...) or backticks must
|
||||
// not be interpretable (issue #476). JSON.stringify is not shell quoting.
|
||||
execFileSync('git', ['check-ignore', '--quiet', absPath], {
|
||||
cwd,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export function browserOpenCommand(url, {
|
||||
platform = process.platform,
|
||||
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
|
||||
} = {}) {
|
||||
if (platform === 'darwin') return { command: 'open', args: [url] };
|
||||
if (platform === 'win32') return { command: comspec, args: ['/c', 'start', '', url] };
|
||||
return { command: 'xdg-open', args: [url] };
|
||||
}
|
||||
|
||||
export function openSystemBrowser(url, {
|
||||
platform = process.platform,
|
||||
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
|
||||
spawnImpl = spawn,
|
||||
} = {}) {
|
||||
const { command, args } = browserOpenCommand(url, { platform, comspec });
|
||||
try {
|
||||
const child = spawnImpl(command, args, { stdio: 'ignore', detached: true });
|
||||
child.on('error', () => {});
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -96,31 +96,38 @@ function* rank(items, input, idFor = item => item.id) {
|
||||
.map(entry => entry.item);
|
||||
}
|
||||
|
||||
// Two independent exclusions, and either one is enough to hold a world back.
|
||||
// Rating grades quality: a 3-star earns a second ticket, a 1-star marginal keep
|
||||
// leaves the pool. Breadth says whether a world can serve an arbitrary build at
|
||||
// all, so a niche world leaves however good it is, keeping its approval for
|
||||
// direct briefs. Breadth was split out of rating because the only way to hold a
|
||||
// narrow world back used to be calling it marginal, which made "excellent but
|
||||
// narrow" unrecordable and corrupted ratings as a calibration signal.
|
||||
// Rating sets how many tickets a world holds; breadth decides whether it draws
|
||||
// at all. A niche world leaves the pool however good it is, keeping its approval
|
||||
// for direct briefs. Breadth was split out of rating because the only way to
|
||||
// hold a narrow world back used to be calling it marginal, which made "excellent
|
||||
// but narrow" unrecordable and corrupted ratings as a calibration signal.
|
||||
//
|
||||
// Two tickets for a 3-star, one for everything else, was too sharp. Measured
|
||||
// against the catalog as it stood: 3-star worlds absorbed 57% of the graphic
|
||||
// draw from 65 of 163 eligible worlds, 46% of atmosphere from 13 of 43, and
|
||||
// 75% of interaction from 15 of 25. The reviewer's complaint, that the same
|
||||
// worlds keep coming back, is what a rating multiplier does to a pool whose
|
||||
// thinnest tier holds 25 worlds.
|
||||
//
|
||||
// So a 3-star no longer outdraws a 2-star, and a 1-star draws at half rather
|
||||
// than not at all. A marginal keep is still worth showing sometimes: the
|
||||
// judgement it records is "narrow or unexceptional", not "wrong", and excluding
|
||||
// it entirely made a rating do a job breadth already does properly.
|
||||
const RATING_TICKETS = { 1: 1, 2: 2, 3: 2 };
|
||||
const ticketsForRating = rating => RATING_TICKETS[rating] ?? 2;
|
||||
|
||||
function challengerTickets(pool) {
|
||||
return pool.flatMap(concept => {
|
||||
const rating = concept.review?.rating;
|
||||
if (rating === 1 || concept.review?.breadth === 'niche') return [];
|
||||
return rating === 3
|
||||
? [{ concept, ticket: 0 }, { concept, ticket: 1 }]
|
||||
: [{ concept, ticket: 0 }];
|
||||
if (concept.review?.breadth === 'niche') return [];
|
||||
return Array.from({ length: ticketsForRating(concept.review?.rating) },
|
||||
(_, ticket) => ({ concept, ticket }));
|
||||
});
|
||||
}
|
||||
|
||||
function compositionTickets(pool) {
|
||||
return pool.flatMap(composition => {
|
||||
const rating = composition.review?.rating;
|
||||
if (rating === 1) return [];
|
||||
return rating === 3
|
||||
? [{ composition, ticket: 0 }, { composition, ticket: 1 }]
|
||||
: [{ composition, ticket: 0 }];
|
||||
});
|
||||
return pool.flatMap(composition => Array.from(
|
||||
{ length: ticketsForRating(composition.review?.rating) },
|
||||
(_, ticket) => ({ composition, ticket })));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -117,6 +117,23 @@ export function checkDesignDrift({ designPath, projectRoot, threshold = 25 }) {
|
||||
* a section can be absent because it never applied, so this is reported as a
|
||||
* documentation gap for a human to judge, never as an error.
|
||||
*/
|
||||
function hasCoverageValue(value) {
|
||||
if (Array.isArray(value)) return value.some(hasCoverageValue);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.values(value).some(hasCoverageValue);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 && !/^(?:\[\s*\]|\{\s*\})$/.test(trimmed);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const SEED_DESIGN_MARKERS = ['/', '$'].map((prefix) =>
|
||||
'<!-- SEED: established with the user before implementation; '
|
||||
+ `re-run ${prefix}impeccable document once there's code to capture the actual tokens and components. -->`
|
||||
);
|
||||
|
||||
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
||||
if (!design || typeof parseDesignMd !== 'function') return [];
|
||||
let model;
|
||||
@@ -125,8 +142,12 @@ export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const missing = ['colors', 'typography', 'components']
|
||||
.filter((section) => !model[section]);
|
||||
const isSeed = SEED_DESIGN_MARKERS.some((marker) => design.includes(marker));
|
||||
const requiredSections = isSeed
|
||||
? ['colors', 'typography']
|
||||
: ['colors', 'typography', 'components'];
|
||||
const missing = requiredSections
|
||||
.filter((section) => !model[section] && !hasCoverageValue(model.frontmatter?.[section]));
|
||||
if (!missing.length) return [];
|
||||
return [finding({
|
||||
id: 'design-md-coverage',
|
||||
@@ -223,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/;
|
||||
// * bundle-relative: node ".agents/.../hook.mjs"
|
||||
// * legacy unquoted: node .claude/.../hook.mjs
|
||||
// * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical)
|
||||
// * absolute: node "/Users/.../hook.mjs" (user-level installs)
|
||||
// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since
|
||||
// the shell-injection fix; older installs double-quote)
|
||||
// * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs"
|
||||
// A quoted path wins; the guard's two occurrences are identical, so the first
|
||||
// quoted match is the path. Otherwise fall back to the whitespace/metachar-
|
||||
@@ -234,6 +256,12 @@ function hookScriptTokenFrom(command) {
|
||||
if (!HOOK_MARKER.test(str)) return null;
|
||||
const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/);
|
||||
if (quoted) return quoted[1];
|
||||
// A path containing an apostrophe serializes as '\'' inside single quotes;
|
||||
// no regex reassembles that, and the bare fallback would misread a fragment
|
||||
// of it, so return null: the caller never asserts on a path it can't parse.
|
||||
if (str.includes("'\\''")) return null;
|
||||
const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/);
|
||||
if (singleQuoted) return singleQuoted[1];
|
||||
const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/);
|
||||
return bare ? bare[1] : null;
|
||||
}
|
||||
|
||||
@@ -97,23 +97,20 @@
|
||||
return { value: c.value, label: c.label };
|
||||
});
|
||||
|
||||
const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions'];
|
||||
const LIVE_UI_SURFACES = [
|
||||
{ key: 'global-bottom-bar', ids: [PREFIX + '-global-bar', PREFIX + '-global-bar-brand', PREFIX + '-pick-toggle', PREFIX + '-insert-toggle', PREFIX + '-detect-toggle', PREFIX + '-detect-badge', PREFIX + '-design-toggle', PREFIX + '-page-chat', PREFIX + '-page-chat-input', PREFIX + '-page-chat-voice', PREFIX + '-page-chat-send'] },
|
||||
{ key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] },
|
||||
{ key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] },
|
||||
{ key: 'action-picker', ids: [PREFIX + '-picker'] },
|
||||
{ key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] },
|
||||
{ key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] },
|
||||
{ key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] },
|
||||
{ key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] },
|
||||
{ key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] },
|
||||
{ key: 'insert-mode-chrome', ids: [PREFIX + '-insert-line', PREFIX + '-insert-placeholder', PREFIX + '-placeholder-resize', PREFIX + '-insert-input', PREFIX + '-insert-voice', PREFIX + '-insert-create', PREFIX + '-insert-create-tooltip'] },
|
||||
{ key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] },
|
||||
{ key: 'design-system-panel', ids: [PREFIX + '-design-host'] },
|
||||
{ key: 'toasts-and-errors', ids: [PREFIX + '-toast', PREFIX + '-mount-error'] },
|
||||
{ key: 'css-isolation-boundary', ids: [PREFIX + '-root'] },
|
||||
];
|
||||
// The Live chrome inventory (which surfaces exist, and the element ids each
|
||||
// one owns) comes from the canonical source, skill/scripts/live/ui-surfaces.mjs,
|
||||
// which the /live.js assembler serializes into these globals alongside the
|
||||
// token/port/vocabulary. This file is served raw and injected as a classic
|
||||
// script, so it cannot import that module; the private impeccable-site repo
|
||||
// imports it directly to check its Live UI lab holds a snapshot for every
|
||||
// surface, which only works while the list has exactly one definition.
|
||||
// Add a surface in ui-surfaces.mjs, not here.
|
||||
const LIVE_CHROME_MOUNT_CONTRACT = Array.isArray(window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__)
|
||||
? window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__
|
||||
: ['root', 'transport', 'state', 'actions'];
|
||||
const LIVE_UI_SURFACES = Array.isArray(window.__IMPECCABLE_LIVE_UI_SURFACES__)
|
||||
? window.__IMPECCABLE_LIVE_UI_SURFACES__
|
||||
: [];
|
||||
const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))];
|
||||
|
||||
//
|
||||
@@ -3766,7 +3763,10 @@
|
||||
const container = copyEditContainerContext(contextElement);
|
||||
if (container) for (const op of ops) op.container = container;
|
||||
try {
|
||||
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', {
|
||||
// Token in the query string as well as the body: the URL token is what
|
||||
// authorizes the CORS preflight when the page runs on a non-loopback
|
||||
// dev host (ddev, Valet), since the preflight carries no request body.
|
||||
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -7150,7 +7150,10 @@
|
||||
console.debug('[impeccable] Dropped optional live event:', err);
|
||||
return null;
|
||||
}
|
||||
const doSend = () => fetch('http://localhost:' + PORT + '/events', {
|
||||
// Token in the query string as well as the body: the URL token is what
|
||||
// authorizes the CORS preflight when the page runs on a non-loopback
|
||||
// dev host (ddev, Valet), since the preflight carries no request body.
|
||||
const doSend = () => fetch('http://localhost:' + PORT + '/events?token=' + encodeURIComponent(TOKEN), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(msg),
|
||||
@@ -11969,7 +11972,9 @@ void main() {
|
||||
rules: [
|
||||
...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
|
||||
...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
|
||||
...(md.layout?.rules || []).map((r) => ({ ...r, section: 'layout' })),
|
||||
...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
|
||||
...(md.shapes?.rules || []).map((r) => ({ ...r, section: 'shapes' })),
|
||||
],
|
||||
dos: md.dosDonts?.dos || [],
|
||||
donts: md.dosDonts?.donts || [],
|
||||
|
||||
@@ -14,10 +14,12 @@ import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
const BATCH_OP_TEXT_LIMIT = 240;
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
||||
const repairLines = batch?.repair ? [
|
||||
const compactBatch = compactBatchForPrompt(batch);
|
||||
const repairLines = compactBatch.repair ? [
|
||||
'',
|
||||
'Repair mode:',
|
||||
'- The previous Apply attempt changed source, but validation failed.',
|
||||
@@ -28,7 +30,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
||||
'- If failures or candidates show edited text is also a lookup key, update coupled count, animation, icon, image, asset, style, or metadata keys in the current source, or fail that entry without partial edits.',
|
||||
'- Keep failed and notes as arrays.',
|
||||
'- Return the same canonical JSON shape after repair.',
|
||||
JSON.stringify(batch.repair, null, 2),
|
||||
JSON.stringify(compactBatch.repair, null, 2),
|
||||
] : [];
|
||||
return [
|
||||
'You are the Impeccable staged copy-edit batch applier.',
|
||||
@@ -80,7 +82,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
||||
...repairLines,
|
||||
'',
|
||||
'Staged copy-edit batch:',
|
||||
JSON.stringify(compactBatchForPrompt(batch), null, 2),
|
||||
JSON.stringify(compactBatch, null, 2),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -292,7 +294,7 @@ function readManualEditValidationScript(cwd) {
|
||||
function compactBatchForPrompt(batch) {
|
||||
return {
|
||||
pageUrl: batch?.pageUrl || null,
|
||||
repair: batch?.repair || undefined,
|
||||
repair: compactBatchRepair(batch?.repair),
|
||||
entries: (batch?.entries || []).map((entry) => ({
|
||||
id: entry.id,
|
||||
pageUrl: entry.pageUrl,
|
||||
@@ -300,7 +302,71 @@ function compactBatchForPrompt(batch) {
|
||||
element: compactContextForBatch(entry.element),
|
||||
ops: (entry.ops || []).map(compactBatchOp),
|
||||
})),
|
||||
candidates: batch?.candidates || [],
|
||||
candidates: compactBatchCandidates(batch?.candidates),
|
||||
};
|
||||
}
|
||||
|
||||
function compactBatchRepair(repair) {
|
||||
if (!repair || typeof repair !== 'object') return undefined;
|
||||
return {
|
||||
status: compactBatchString(repair.status),
|
||||
attempt: normalizeOptionalBatchNumber(repair.attempt),
|
||||
attempts: normalizeOptionalBatchNumber(repair.attempts),
|
||||
maxAttempts: normalizeOptionalBatchNumber(repair.maxAttempts),
|
||||
reason: compactBatchString(repair.reason),
|
||||
transactionId: compactBatchString(repair.transactionId),
|
||||
pageUrl: compactBatchString(repair.pageUrl),
|
||||
failures: compactBatchDiagnostics(repair.failures),
|
||||
files: compactBatchStringList(repair.files, 20),
|
||||
};
|
||||
}
|
||||
|
||||
function compactBatchDiagnostics(items, depth = 0) {
|
||||
if (!Array.isArray(items)) return undefined;
|
||||
return items.slice(0, 12).map((item) => ({
|
||||
entryId: compactBatchString(item?.entryId || item?.id),
|
||||
reason: compactBatchString(item?.reason || item?.kind),
|
||||
detail: compactBatchString(item?.detail),
|
||||
message: compactBatchString(item?.message),
|
||||
file: compactBatchString(item?.file || item?.relativeFile),
|
||||
line: normalizeOptionalBatchNumber(item?.line),
|
||||
ref: compactBatchString(item?.ref),
|
||||
marker: compactBatchString(item?.marker),
|
||||
files: compactBatchStringList(item?.files, 8),
|
||||
candidates: depth < 2 ? compactBatchSourceMatches(item?.candidates, 8) : undefined,
|
||||
failures: depth < 2 ? compactBatchDiagnostics(item?.failures, depth + 1) : undefined,
|
||||
checks: depth < 2 ? compactBatchDiagnostics(item?.checks, depth + 1) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function compactBatchCandidates(candidates) {
|
||||
return (Array.isArray(candidates) ? candidates : [])
|
||||
.slice(0, 24)
|
||||
.map((candidate) => ({
|
||||
entryId: compactBatchString(candidate?.entryId),
|
||||
ref: compactBatchString(candidate?.ref),
|
||||
sourceHint: compactBatchSourceMatch(candidate?.sourceHint),
|
||||
textMatches: compactBatchSourceMatches(candidate?.textMatches, 8),
|
||||
objectKeyMatches: compactBatchSourceMatches(candidate?.objectKeyMatches, 8),
|
||||
contextTextMatches: compactBatchSourceMatches(candidate?.contextTextMatches, 8),
|
||||
locatorMatches: compactBatchSourceMatches(candidate?.locatorMatches, 6),
|
||||
}));
|
||||
}
|
||||
|
||||
function compactBatchSourceMatches(matches, limit) {
|
||||
if (!Array.isArray(matches)) return undefined;
|
||||
return matches.slice(0, limit).map(compactBatchSourceMatch).filter(Boolean);
|
||||
}
|
||||
|
||||
function compactBatchSourceMatch(match) {
|
||||
if (!match || typeof match !== 'object') return null;
|
||||
return {
|
||||
file: compactBatchString(match.relativeFile || match.file),
|
||||
line: normalizeBatchNumber(match.line),
|
||||
column: normalizeBatchNumber(match.column),
|
||||
kind: compactBatchString(match.kind),
|
||||
reason: compactBatchString(match.reason || match.kind),
|
||||
status: compactBatchString(match.status),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,25 +377,77 @@ function compactBatchOp(op) {
|
||||
contextRef: op.contextRef,
|
||||
tag: op.tag,
|
||||
elementId: op.elementId,
|
||||
classes: op.classes,
|
||||
classes: compactBatchStringList(op.classes, 24),
|
||||
originalText: op.originalText,
|
||||
newText: op.newText,
|
||||
deleted: op.deleted === true || undefined,
|
||||
sourceHint: op.sourceHint,
|
||||
sourceHint: normalizeBatchSourceHint(op.sourceHint),
|
||||
leaf: compactContextForBatch(op.leaf),
|
||||
nearbyEditableTexts: Array.isArray(op.nearbyEditableTexts) ? op.nearbyEditableTexts.slice(0, 8) : [],
|
||||
nearbyEditableTexts: compactNearbyBatchTexts(op.nearbyEditableTexts),
|
||||
container: compactContextForBatch(op.container),
|
||||
contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 12) : [],
|
||||
contextHints: compactBatchStringList(op.contextHints, 12),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBatchSourceHint(hint) {
|
||||
if (!hint || typeof hint !== 'object') return null;
|
||||
let line = normalizeBatchNumber(hint.line);
|
||||
let column = normalizeBatchNumber(hint.column);
|
||||
if ((line === null || column === null) && typeof hint.loc === 'string') {
|
||||
const match = hint.loc.match(/^(\d+)(?::(\d+))?/);
|
||||
if (match) {
|
||||
line = Number(match[1]);
|
||||
if (match[2]) column = Number(match[2]);
|
||||
}
|
||||
}
|
||||
return {
|
||||
file: compactBatchString(hint.file) || '',
|
||||
loc: compactBatchString(hint.loc) || '',
|
||||
line,
|
||||
column,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBatchNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function normalizeOptionalBatchNumber(value) {
|
||||
const number = normalizeBatchNumber(value);
|
||||
return number === null ? undefined : number;
|
||||
}
|
||||
|
||||
function compactNearbyBatchTexts(items) {
|
||||
return (Array.isArray(items) ? items : [])
|
||||
.slice(0, 8)
|
||||
.map((item) => typeof item === 'string' ? { text: truncate(item, BATCH_OP_TEXT_LIMIT) } : {
|
||||
ref: compactBatchString(item?.ref),
|
||||
tag: compactBatchString(item?.tag),
|
||||
classes: compactBatchStringList(item?.classes, 24),
|
||||
text: compactBatchString(item?.text),
|
||||
});
|
||||
}
|
||||
|
||||
function compactBatchStringList(items, limit) {
|
||||
return (Array.isArray(items) ? items : [])
|
||||
.slice(0, limit)
|
||||
.filter((item) => typeof item === 'string')
|
||||
.map((item) => truncate(item, BATCH_OP_TEXT_LIMIT));
|
||||
}
|
||||
|
||||
function compactBatchString(value) {
|
||||
return typeof value === 'string' ? truncate(value, BATCH_OP_TEXT_LIMIT) : undefined;
|
||||
}
|
||||
|
||||
function compactContextForBatch(value) {
|
||||
if (!value || typeof value !== 'object') return value || null;
|
||||
return {
|
||||
ref: value.ref,
|
||||
tagName: value.tagName,
|
||||
id: value.id,
|
||||
classes: value.classes,
|
||||
ref: compactBatchString(value.ref),
|
||||
tagName: compactBatchString(value.tagName),
|
||||
id: compactBatchString(value.id),
|
||||
classes: compactBatchStringList(value.classes, 24),
|
||||
textContent: truncate(value.textContent, 900),
|
||||
outerHTML: truncate(stripLiveRuntimeHtml(value.outerHTML), 1800),
|
||||
};
|
||||
@@ -470,12 +588,11 @@ function runClaude(prompt, { cwd, env, resultPath, logPath, timeoutMs = DEFAULT_
|
||||
if (env.IMPECCABLE_LIVE_COPY_AGENT_MODEL) {
|
||||
args.push('--model', env.IMPECCABLE_LIVE_COPY_AGENT_MODEL);
|
||||
}
|
||||
args.push(prompt);
|
||||
// Forward env as-is so CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY flow
|
||||
// through. On macOS, `claude /login` stores creds in the Keychain, which a
|
||||
// non-TTY subprocess cannot read; setting CLAUDE_CODE_OAUTH_TOKEN (via
|
||||
// `claude setup-token`) is the supported headless auth path.
|
||||
return runAgentProcess('claude', args, '', { cwd, env, logPath, timeoutMs, mirrorOutputPath: resultPath });
|
||||
return runAgentProcess('claude', args, prompt, { cwd, env, logPath, timeoutMs, mirrorOutputPath: resultPath });
|
||||
}
|
||||
|
||||
function runAgentProcess(command, args, stdin, { cwd, env, logPath, timeoutMs, mirrorOutputPath }) {
|
||||
|
||||
@@ -689,16 +689,24 @@ function isLoopbackOrigin(origin) {
|
||||
function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
return (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${state.port}`);
|
||||
// Loopback-restricted CORS. Reflect the caller's Origin only when it is a
|
||||
// loopback origin, always paired with `Vary: Origin` so an intermediary
|
||||
// cache never serves a response authorized for one origin to another. A
|
||||
// remote page (e.g. https://evil.example probing the port from a tab open
|
||||
// on the same machine) gets no Access-Control-Allow-Origin, so its
|
||||
// JS-initiated fetch cannot read any response. Requests with no Origin
|
||||
// header (script tags, curl, the agent's own fetches) are not subject to
|
||||
// CORS and keep working; no ACAO header is needed for them.
|
||||
// Token-or-loopback CORS. Reflect the caller's Origin when it is a
|
||||
// loopback origin OR the request carries the valid session token, always
|
||||
// paired with `Vary: Origin` so an intermediary cache never serves a
|
||||
// response authorized for one origin to another. A remote page (e.g.
|
||||
// https://evil.example probing the port from a tab open on the same
|
||||
// machine) has no token and gets no Access-Control-Allow-Origin, so its
|
||||
// JS-initiated fetch cannot read any response. The token branch exists for
|
||||
// dev servers on non-localhost loopback aliases (ddev's *.ddev.site,
|
||||
// Valet's *.test, hosts-file entries): the injected classic <script src>
|
||||
// delivers the token to the page regardless of origin, every overlay
|
||||
// request carries it in the query string (preflights included, since
|
||||
// OPTIONS hits the same URL), and a token bearer is already fully
|
||||
// authorized on every route — the token is the security boundary, not the
|
||||
// origin. Requests with no Origin header (script tags, curl, the agent's
|
||||
// own fetches) are not subject to CORS and keep working; no ACAO header
|
||||
// is needed for them.
|
||||
const origin = req.headers.origin;
|
||||
if (origin && isLoopbackOrigin(origin)) {
|
||||
if (origin && (isLoopbackOrigin(origin) || url.searchParams.get('token') === state.token)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
@@ -865,7 +873,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
// { present, parsed, sidecar, hasMd, hasSidecar,
|
||||
// mdNewerThanJson, parseError?, sidecarError? }
|
||||
// - parsed: output of parseDesignMd (frontmatter
|
||||
// + six canonical sections) when DESIGN.md exists.
|
||||
// + the canonical sections) when DESIGN.md exists.
|
||||
// - sidecar: .impeccable/design.json contents when present.
|
||||
// Expected shape: schemaVersion 2, carrying
|
||||
// extensions + components + narrative.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* node live.mjs --help
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -316,11 +316,17 @@ function globToRegex(pattern) {
|
||||
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
// argv form, never a shell: string interpolation into double quotes would
|
||||
// let a `"` or `$(...)` in any future caller's arg escape into the shell
|
||||
// (issue #476).
|
||||
return execFileSync(process.execPath, [scriptPath, ...args], {
|
||||
encoding: 'utf-8',
|
||||
cwd: options.cwd || process.cwd(),
|
||||
timeout: 15_000,
|
||||
});
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
// execFileSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs';
|
||||
|
||||
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
|
||||
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
|
||||
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
|
||||
@@ -32,7 +34,20 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re
|
||||
}));
|
||||
}
|
||||
|
||||
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', appRoot = null, parts }) {
|
||||
export function assembleLiveBrowserScript({
|
||||
token,
|
||||
port,
|
||||
vocabulary,
|
||||
commandPrefix = '/',
|
||||
appRoot = null,
|
||||
parts,
|
||||
// Defaulted rather than threaded through live-server.mjs: the browser bundle
|
||||
// must always carry the canonical inventory, and a default makes that true by
|
||||
// construction instead of by every caller remembering to pass it. Overridable
|
||||
// so tests can assemble with a stand-in.
|
||||
uiSurfaces = LIVE_UI_SURFACES,
|
||||
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
|
||||
}) {
|
||||
const prelude =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${port};\n` +
|
||||
@@ -44,7 +59,14 @@ export function assembleLiveBrowserScript({ token, port, vocabulary, commandPref
|
||||
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
|
||||
// Canonical command vocabulary (values + labels + icons). live-browser.js
|
||||
// builds its action picker from this instead of an inline copy.
|
||||
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n`;
|
||||
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n` +
|
||||
// Canonical Live chrome inventory from live/ui-surfaces.mjs. live-browser.js
|
||||
// is a classic script and cannot import an ES module at runtime, so the list
|
||||
// is serialized here and read off the global there. Node consumers (this
|
||||
// repo's tests, the impeccable-site Live UI lab) import the module directly,
|
||||
// which is what keeps the two from drifting.
|
||||
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
|
||||
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
|
||||
|
||||
const body = parts.map((part) => {
|
||||
const file = part.file || path.basename(part.path || '');
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* Framework-neutral Impeccable live chrome contract.
|
||||
*
|
||||
* The production browser bundle is intentionally plain DOM so Svelte, React,
|
||||
* Vue, and static adapters can all mount the same chrome. This module is the
|
||||
* testable contract/inventory for that bundle; live-browser.js mirrors these
|
||||
* values at runtime because it is served as a standalone script.
|
||||
*/
|
||||
|
||||
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
|
||||
'root',
|
||||
'transport',
|
||||
'state',
|
||||
'actions',
|
||||
]);
|
||||
|
||||
export const LIVE_UI_SURFACES = Object.freeze([
|
||||
{
|
||||
key: 'global-bottom-bar',
|
||||
ids: [
|
||||
'impeccable-live-global-bar',
|
||||
'impeccable-live-global-bar-brand',
|
||||
'impeccable-live-pick-toggle',
|
||||
'impeccable-live-insert-toggle',
|
||||
'impeccable-live-detect-toggle',
|
||||
'impeccable-live-detect-badge',
|
||||
'impeccable-live-design-toggle',
|
||||
'impeccable-live-page-chat',
|
||||
'impeccable-live-page-chat-input',
|
||||
'impeccable-live-page-chat-voice',
|
||||
],
|
||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
|
||||
},
|
||||
{
|
||||
key: 'pending-copy-edit-dock',
|
||||
ids: ['impeccable-live-pending-dock'],
|
||||
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
|
||||
},
|
||||
{
|
||||
key: 'element-selection-chrome',
|
||||
ids: [
|
||||
'impeccable-live-highlight',
|
||||
'impeccable-live-tooltip',
|
||||
'impeccable-live-bar',
|
||||
'impeccable-live-selection-pill',
|
||||
'impeccable-live-input',
|
||||
'impeccable-live-configure-voice',
|
||||
'impeccable-live-configure-bar-tooltip',
|
||||
],
|
||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
|
||||
},
|
||||
{
|
||||
key: 'action-picker',
|
||||
ids: ['impeccable-live-picker'],
|
||||
states: ['closed', 'open', 'option-hover', 'option-focus'],
|
||||
},
|
||||
{
|
||||
key: 'edit-chrome',
|
||||
ids: ['impeccable-live-edit-badge'],
|
||||
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
|
||||
},
|
||||
{
|
||||
key: 'generating-row',
|
||||
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
|
||||
states: ['action-label', 'animated-dots', 'generating', 'done'],
|
||||
},
|
||||
{
|
||||
key: 'variant-cycling-row',
|
||||
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
|
||||
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
|
||||
},
|
||||
{
|
||||
key: 'variant-params-panel',
|
||||
ids: ['impeccable-live-params-panel'],
|
||||
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
|
||||
},
|
||||
{
|
||||
key: 'saving-confirmed-rows',
|
||||
ids: ['impeccable-live-bar'],
|
||||
states: ['saving', 'applying-variant', 'confirmed'],
|
||||
},
|
||||
{
|
||||
key: 'insert-mode-chrome',
|
||||
ids: [
|
||||
'impeccable-live-insert-line',
|
||||
'impeccable-live-insert-placeholder',
|
||||
'impeccable-live-placeholder-resize',
|
||||
'impeccable-live-insert-input',
|
||||
'impeccable-live-insert-voice',
|
||||
'impeccable-live-insert-create',
|
||||
'impeccable-live-insert-create-tooltip',
|
||||
],
|
||||
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
|
||||
},
|
||||
{
|
||||
key: 'annotation-chrome',
|
||||
ids: [
|
||||
'impeccable-live-annot',
|
||||
'impeccable-live-annot-svg',
|
||||
'impeccable-live-annot-pins',
|
||||
'impeccable-live-annot-clear',
|
||||
],
|
||||
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
|
||||
},
|
||||
{
|
||||
key: 'design-system-panel',
|
||||
ids: ['impeccable-live-design-host'],
|
||||
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
|
||||
},
|
||||
{
|
||||
key: 'toasts-and-errors',
|
||||
ids: ['impeccable-live-toast'],
|
||||
states: ['normal', 'error', 'no-variants-mounted'],
|
||||
},
|
||||
{
|
||||
key: 'css-isolation-boundary',
|
||||
ids: ['impeccable-live-root'],
|
||||
states: ['shadow-root', 'style-tags', 'hostile-css'],
|
||||
},
|
||||
]);
|
||||
|
||||
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
|
||||
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
|
||||
]);
|
||||
|
||||
export function resolveLiveUiRoot(env = globalThis) {
|
||||
const doc = env?.document;
|
||||
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|
||||
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
|
||||
if (explicit && typeof explicit.appendChild === 'function') return explicit;
|
||||
return doc?.body || null;
|
||||
}
|
||||
|
||||
export function getLiveUiElementById(id, env = globalThis) {
|
||||
const doc = env?.document;
|
||||
const root = resolveLiveUiRoot(env);
|
||||
if (!id) return null;
|
||||
if (root?.getElementById) {
|
||||
const found = root.getElementById(id);
|
||||
if (found) return found;
|
||||
}
|
||||
if (root?.querySelector) {
|
||||
const found = root.querySelector('#' + escapeCssIdent(id));
|
||||
if (found) return found;
|
||||
}
|
||||
return doc?.getElementById?.(id) || null;
|
||||
}
|
||||
|
||||
export function appendToLiveUiRoot(el, env = globalThis) {
|
||||
const root = resolveLiveUiRoot(env);
|
||||
if (!root) throw new Error('Impeccable live UI root is not available');
|
||||
root.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
|
||||
const doc = env?.document;
|
||||
const root = resolveLiveUiRoot(env);
|
||||
if (root && root !== doc?.body) {
|
||||
root.appendChild(styleEl);
|
||||
} else {
|
||||
(doc?.head || doc?.body || root).appendChild(styleEl);
|
||||
}
|
||||
return styleEl;
|
||||
}
|
||||
|
||||
export function activeElementDeep(doc = globalThis.document) {
|
||||
let active = doc?.activeElement || null;
|
||||
while (active?.shadowRoot?.activeElement) {
|
||||
active = active.shadowRoot.activeElement;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
function escapeCssIdent(value) {
|
||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||
return CSS.escape(String(value));
|
||||
}
|
||||
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Canonical inventory of the Live overlay's UI surfaces: one entry per piece of
|
||||
* chrome Live mounts on the user's page, with the element ids that make it up.
|
||||
*
|
||||
* Single source of truth, consumed by:
|
||||
* - skill/scripts/live/browser-script-parts.mjs — serializes this into
|
||||
* window.__IMPECCABLE_LIVE_UI_SURFACES__ in the /live.js prelude.
|
||||
* - skill/scripts/live-browser.js — publishes it on
|
||||
* window.__IMPECCABLE_LIVE_CHROME_CORE__ for adapters and E2E probes. That
|
||||
* file is served raw and injected as a classic <script>, so it cannot
|
||||
* import this module at runtime; it reads the injected global instead, the
|
||||
* same path live/vocabulary.mjs already takes for the command palette.
|
||||
* - the private impeccable-site repo — site/components/LiveUiGallery.astro
|
||||
* and tests/live-ui-lab.test.mjs import LIVE_UI_SURFACES at build time and
|
||||
* fail the site build when the Live UI lab has no snapshot for a surface
|
||||
* defined here. That guard only guards if it reads this list rather than a
|
||||
* copy the site keeps, so this module must stay importable from Node.
|
||||
* Renaming a key or the module is a breaking change for that build; the
|
||||
* list was briefly inlined into live-browser.js and the site had to parse
|
||||
* it back out with a regex.
|
||||
*
|
||||
* Add a surface here and both the browser bundle and the site lab follow.
|
||||
*/
|
||||
|
||||
/** Id prefix every Live chrome element carries. Mirrored by PREFIX in live-browser.js. */
|
||||
export const LIVE_UI_PREFIX = 'impeccable-live';
|
||||
|
||||
const id = (suffix) => `${LIVE_UI_PREFIX}-${suffix}`;
|
||||
|
||||
/**
|
||||
* The mount contract every Live chrome adapter (DOM, Svelte, ...) satisfies.
|
||||
* Published alongside the surfaces on __IMPECCABLE_LIVE_CHROME_CORE__.
|
||||
*/
|
||||
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze(['root', 'transport', 'state', 'actions']);
|
||||
|
||||
export const LIVE_UI_SURFACES = Object.freeze([
|
||||
{
|
||||
key: 'global-bottom-bar',
|
||||
ids: [
|
||||
id('global-bar'), id('global-bar-brand'), id('pick-toggle'), id('insert-toggle'),
|
||||
id('detect-toggle'), id('detect-badge'), id('design-toggle'), id('page-chat'),
|
||||
id('page-chat-input'), id('page-chat-voice'), id('page-chat-send'),
|
||||
],
|
||||
},
|
||||
{ key: 'pending-copy-edit-dock', ids: [id('pending-dock')] },
|
||||
{
|
||||
key: 'element-selection-chrome',
|
||||
ids: [
|
||||
id('highlight'), id('tooltip'), id('bar'), id('selection-pill'), id('input'),
|
||||
id('configure-voice'), id('configure-bar-tooltip'),
|
||||
],
|
||||
},
|
||||
{ key: 'action-picker', ids: [id('picker')] },
|
||||
{ key: 'edit-chrome', ids: [id('edit-badge')] },
|
||||
{ key: 'generating-row', ids: [id('bar'), id('shader')] },
|
||||
{ key: 'variant-cycling-row', ids: [id('bar'), id('params-panel')] },
|
||||
{ key: 'variant-params-panel', ids: [id('params-panel')] },
|
||||
{ key: 'saving-confirmed-rows', ids: [id('bar')] },
|
||||
{
|
||||
key: 'insert-mode-chrome',
|
||||
ids: [
|
||||
id('insert-line'), id('insert-placeholder'), id('placeholder-resize'), id('insert-input'),
|
||||
id('insert-voice'), id('insert-create'), id('insert-create-tooltip'),
|
||||
],
|
||||
},
|
||||
{ key: 'annotation-chrome', ids: [id('annot'), id('annot-svg'), id('annot-pins'), id('annot-clear')] },
|
||||
{ key: 'design-system-panel', ids: [id('design-host')] },
|
||||
{ key: 'toasts-and-errors', ids: [id('toast'), id('mount-error')] },
|
||||
{ key: 'css-isolation-boundary', ids: [id('root')] },
|
||||
].map((surface) => Object.freeze({ ...surface, ids: Object.freeze(surface.ids) })));
|
||||
|
||||
/** Every id any surface owns, de-duplicated, in surface order. */
|
||||
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
|
||||
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
|
||||
]);
|
||||
@@ -21,7 +21,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// All known harness directories
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.github', '.grok',
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
||||
];
|
||||
|
||||
@@ -93,15 +93,17 @@ function commandPrefixForSkillsDir(skillsDir) {
|
||||
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
|
||||
}
|
||||
|
||||
function generatePinnedSkill(command, metadata, commandPrefix) {
|
||||
function generatePinnedSkill(command, metadata, commandPrefix, isCodex) {
|
||||
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
|
||||
const hint = metadata[command]?.argumentHint || '[target]';
|
||||
const providerFrontmatter = isCodex
|
||||
? `metadata:\n argument-hint: "${hint}"`
|
||||
: `argument-hint: "${hint}"\nuser-invocable: true`;
|
||||
|
||||
return `---
|
||||
name: ${command}
|
||||
description: "${desc}"
|
||||
argument-hint: "${hint}"
|
||||
user-invocable: true
|
||||
${providerFrontmatter}
|
||||
---
|
||||
|
||||
${PIN_MARKER}
|
||||
@@ -128,7 +130,7 @@ function pin(command, projectRoot) {
|
||||
|
||||
for (const skillsDir of harnessDirs) {
|
||||
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
|
||||
const content = generatePinnedSkill(command, metadata, commandPrefix);
|
||||
const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$');
|
||||
// Check if skill already exists (and isn't a pin)
|
||||
const skillDir = join(skillsDir, command);
|
||||
if (existsSync(skillDir)) {
|
||||
|
||||
@@ -29,25 +29,50 @@
|
||||
* "materials": ["letterpress", "newsprint"], // optional, rendered as tags
|
||||
* "viewport": "one line: the first-viewport composition", // optional
|
||||
* "case": "one line: the fusion verdict, honest", // optional
|
||||
* "verdict": "competitive", // optional routing tier: "wins" |
|
||||
* // "competitive" | "declined". Declined cards
|
||||
* // render demoted after the full cards:
|
||||
* // narrow, quiet, catalog art as a labeled
|
||||
* // thumb, "Adopt anyway" instead of "Build
|
||||
* // this". Still choosable; never deleted.
|
||||
* "kept": "one line: what the direction kept from this declined world",
|
||||
* "raised": [ { "from": "challenger-x", "raise": "one line" } ],
|
||||
* // assigned card only: donations taken from
|
||||
* // declined challengers, rendered as named
|
||||
* // raise lines under the identity row
|
||||
* "risk": "one line: the honest risk", // optional
|
||||
* "body": "fallback prose when the structured fields are absent",
|
||||
* "sketch": ".impeccable/sketches/assigned.webp", // optional; may not exist
|
||||
* // yet: the page shimmer-waits and polls the
|
||||
* // slot until the file lands, so serve first
|
||||
* // and generate after
|
||||
* "sketch": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
|
||||
* // full-fidelity direction comp (the field
|
||||
* // keeps the sketch era's wire name). May not
|
||||
* // exist yet: the page shimmer-waits and
|
||||
* // polls the slot until the file lands, so
|
||||
* // serve first and generate after
|
||||
* "hero": "https://... or /abs/path.webp", // optional inspiration image;
|
||||
* // rides picture-in-picture when a sketch exists
|
||||
* "board": "https://... or /abs/path.webp" // optional secondary image
|
||||
* }, ...
|
||||
* ],
|
||||
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
|
||||
* // or { "registers": ["safer", "bolder"] } to add
|
||||
* // the register steers beside it: the answer then
|
||||
* // carries "register" and the agent re-runs
|
||||
* // concept-seed with --register <value>
|
||||
* "canon": true, // adds the "Play it straight" standing exit;
|
||||
* // direction rounds only (returns {"optionId":"canon"})
|
||||
* "canonCard": { ... }, // optional: the standing exit as a full card with the
|
||||
* // same anatomy (label, thesis, palette, sketch, ...);
|
||||
* // rendered last and visually subordinate. Without it,
|
||||
* // canon stays a quiet footer action.
|
||||
* "steer": true // adds a free-text steer field returned with any answer
|
||||
* "steer": true, // adds a free-text steer field returned with any answer
|
||||
* "followup": true // this round's pick is not terminal: the server
|
||||
* // stays open awaiting --update with the next
|
||||
* // round (detached mode only), the page shows a
|
||||
* // loading hand instead of goodbye, and the
|
||||
* // answer carries followup:true so --wait knows
|
||||
* // to keep the table. Use it when a decision has
|
||||
* // a known second half, e.g. direction first,
|
||||
* // then the execution contract.
|
||||
* }
|
||||
*
|
||||
* Options render as large cards: the sketch leads when present, with the
|
||||
@@ -79,6 +104,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { openSystemBrowser } from './lib/open-system-browser.mjs';
|
||||
|
||||
function arg(name, fallback = null) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
@@ -123,11 +149,17 @@ function printAnswer(raw) {
|
||||
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code. When your harness only reads files, or runs sandboxed, download them INTO the workspace and open the relative path; a sandboxed viewer rejects absolute paths outside it. They set the craft bar the build must reach.");
|
||||
}
|
||||
if (a.sketch) {
|
||||
console.log('CHOSEN SKETCH: the decision sketch at that path may seed one comp probe; the comp round still renders its full set, because a sketch chose the direction, not the composition.');
|
||||
console.log('CHOSEN COMP: the decision comp at that path is compositional option one. On a comp-led build the comp round adds two variations beside it; on a code-led build it returns at the finish review as the critique reference. Never regenerate it from scratch.');
|
||||
}
|
||||
if (a.optionId === 'canon') {
|
||||
console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.');
|
||||
}
|
||||
if (a.optionId === 'reroll' && a.register) {
|
||||
console.log(`REGISTER: the user steered the next hand to the ${a.register} register. Re-run concept-seed with the same key, the next --reroll round, and --register ${a.register}, then follow what it prints; the register is the user's steering, never yours to pre-select.`);
|
||||
}
|
||||
if (a.followup && a.optionId !== 'reroll') {
|
||||
console.log('FOLLOWUP OPEN: the table stays open and the page is showing a loading hand. Deliver the next round now with --update --key <key> --payload <file>, then collect it with --wait; never leave the page waiting on a round you have not sent.');
|
||||
}
|
||||
} catch { /* raw answer */ }
|
||||
}
|
||||
|
||||
@@ -143,15 +175,17 @@ if (hasFlag('schema')) {
|
||||
title: 'Choose the visual world',
|
||||
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
|
||||
options: [
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/fillmore-handbill.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', case: 'Fuses cleanly: releases map to numbered pages.', sketch: '.impeccable/sketches/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], sketch: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
|
||||
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'MY PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as today’s ballad.', risk: 'Also the direction most runs in this category land on.', sketch: '.impeccable/mocks/decision/model-pick.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', sketch: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'challenger-microfiche', label: 'Microfiche Reader', verdict: 'declined', lineage: 'library microfiche stations', palette: ['#101418', '#9fb4c0'], materials: ['film grain', 'backlit glass'], case: 'Fuses poorly: listeners do not identify with archival retrieval.', kept: 'Total environmental commitment.', hero: 'https://impeccable.style/worlds/cards/archives-microfiche-reader-hero.webp' },
|
||||
],
|
||||
reroll: true,
|
||||
reroll: { registers: ['safer', 'bolder'] },
|
||||
canon: true,
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/canon.webp' },
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', sketch: '.impeccable/mocks/decision/canon.webp' },
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no sketch (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "MY PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. The sketch slot carries the card’s full-fidelity direction comp (the field keeps its wire name for compatibility). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. followup: true keeps the table open after a pick for a second round via --update (direction first, then the execution contract); send the next payload immediately, the page is waiting on it.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -195,12 +229,16 @@ if (hasFlag('wait')) {
|
||||
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
|
||||
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
|
||||
printAnswer(collected);
|
||||
// A re-roll keeps the table open: the server stays alive awaiting --update,
|
||||
// so only the answer file is consumed. Terminal choices clean up fully.
|
||||
let isRerollAnswer = false;
|
||||
try { isRerollAnswer = JSON.parse(collected).optionId === 'reroll'; } catch { /* treat as terminal */ }
|
||||
// A re-roll or a followup-round pick keeps the table open: the server stays
|
||||
// alive awaiting --update, so only the answer file is consumed. Terminal
|
||||
// choices clean up fully.
|
||||
let keepsTableOpen = false;
|
||||
try {
|
||||
const parsedAnswer = JSON.parse(collected);
|
||||
keepsTableOpen = parsedAnswer.optionId === 'reroll' || parsedAnswer.followup === true;
|
||||
} catch { /* treat as terminal */ }
|
||||
try { fs.rmSync(answerFile(key)); } catch { /* already gone */ }
|
||||
if (!isRerollAnswer) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
|
||||
if (!keepsTableOpen) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -301,11 +339,19 @@ function loadRound(json) {
|
||||
sketchSrc: sketchSrc(option.sketch),
|
||||
});
|
||||
options = parsed.options.map(decorate);
|
||||
// The verdict routes rendering: full cards first, then the canon, then the
|
||||
// declined cards dead last in their own payload order. The reorder happens
|
||||
// here so a payload that interleaves them still renders the weighing's
|
||||
// shape, and the deck reads as a gradient of standing: contenders, the
|
||||
// familiar door, then the demoted row.
|
||||
const declined = options.filter((o) => o.verdict === 'declined');
|
||||
options = options.filter((o) => o.verdict !== 'declined');
|
||||
// The standing exit as a full card: same anatomy, reserved id, rendered
|
||||
// subordinate by the page. Without it, canon stays the quiet footer action.
|
||||
if (parsed.canonCard && typeof parsed.canonCard === 'object') {
|
||||
options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
|
||||
}
|
||||
options = [...options, ...declined];
|
||||
}
|
||||
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
|
||||
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
|
||||
@@ -321,7 +367,23 @@ function page() {
|
||||
// and material tags give a text-only direction an immediate identity that
|
||||
// no generation luck can distort.
|
||||
const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
|
||||
const hasBack = (option) => Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
|
||||
const demoted = (option) => option.verdict === 'declined';
|
||||
// Salience parity: a card's imagery weight is capped by the assigned card's.
|
||||
// When the lead card has no media at all (no image generation this round,
|
||||
// and no catalog art of its own), full-bleed catalog art beside a text-only
|
||||
// assigned card would let rendering luck outvote the weighing: users click
|
||||
// the colorful thing. Declined cards are thumb-only regardless; the verdict
|
||||
// demoted them, and a full-bleed hero would promote them right back.
|
||||
const identityRound = !(options[0] && (options[0].sketchSrc || options[0].heroSrc || options[0].boardSrc));
|
||||
// A declined card never renders a full media face, sketch included: even a
|
||||
// declared sketch would buy back the salience the verdict took away.
|
||||
const faceSketch = (option) => demoted(option) ? null : option.sketchSrc;
|
||||
const thumbOnly = (option) => !faceSketch(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
|
||||
const hasMedia = (option) => Boolean(faceSketch(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
|
||||
// The back exists to keep long facts off a card whose front is an image;
|
||||
// a card with no art has no flip chip to reach it, so it gets no back and
|
||||
// the full read lives on the front instead.
|
||||
const hasBack = (option) => hasMedia(option) && Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
|
||||
const anatomy = (option) => {
|
||||
const rows = [];
|
||||
if (option.thesis) rows.push(`<p class="thesis">${esc(option.thesis)}</p>`);
|
||||
@@ -333,10 +395,43 @@ function page() {
|
||||
idBits.push(option.materials.slice(0, 4).map((m) => `<span class="tag">${esc(m)}</span>`).join(''));
|
||||
}
|
||||
if (idBits.length) rows.push(`<div class="identity">${idBits.join('')}</div>`);
|
||||
// Donations from declined challengers render as named raise lines: the
|
||||
// assigned card arrives already raised by the hand it beat, and the raise
|
||||
// is readable, because a raise nobody can read did not happen. One raise
|
||||
// renders inline; several become a compact cycler (click advances), so a
|
||||
// generous hand cannot blow the card out of proportion.
|
||||
if (Array.isArray(option.raised) && option.raised.length) {
|
||||
const nameOf = (id) => options.find((o) => o.id === id)?.label || String(id ?? '');
|
||||
const raiseLines = option.raised.slice(0, 6).map((r) => `<p class="raise"><span class="fact-label">Raised by ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
|
||||
if (raiseLines.length > 1) {
|
||||
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter to see the next raise" aria-label="Raised by the hand; activate to see the next raise">
|
||||
<div class="raises-head"><span class="fact-label">Raised by the hand</span><span class="raises-count" data-raises-count>1/${raiseLines.length}</span></div>
|
||||
${raiseLines.join('')}
|
||||
<span class="sr-live" aria-live="polite"></span>
|
||||
</div>`);
|
||||
} else {
|
||||
rows.push(`<div class="raises">${raiseLines[0]}</div>`);
|
||||
}
|
||||
}
|
||||
// Demoted art stays reachable as a labeled thumb: the catalog world
|
||||
// explains where the direction comes from without buying it back the
|
||||
// salience the verdict took away.
|
||||
if (thumbOnly(option)) {
|
||||
rows.push(`<figure class="inspo" title="Inspiration: the world this direction draws from. Your page will not look like this image."><img src="${esc(option.heroSrc || option.boardSrc)}" alt=""><figcaption>inspired by</figcaption></figure>`);
|
||||
}
|
||||
// The front carries only what the choice needs: thesis, identity, and the
|
||||
// honest risk clamped to two lines. First viewport and the case read on
|
||||
// the card's back; once the sketch lands, the first viewport is a picture.
|
||||
rows.push(fact('Risk', option.risk, 'clamp'));
|
||||
// With no art there is no back, so the full read fills the room the
|
||||
// image would have taken.
|
||||
if (hasMedia(option)) {
|
||||
rows.push(fact('Risk', option.risk, 'clamp'));
|
||||
} else {
|
||||
rows.push(fact('First viewport', option.viewport));
|
||||
rows.push(fact('The case', option.case));
|
||||
rows.push(fact('Kept', option.kept));
|
||||
rows.push(fact('Risk', option.risk));
|
||||
}
|
||||
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
|
||||
else if (option.body && option.thesis && !hasBack(option)) rows.push(`<p class="detail more">${esc(option.body)}</p>`);
|
||||
return rows.join('\n ');
|
||||
@@ -344,6 +439,7 @@ function page() {
|
||||
const backFacts = (option) => [
|
||||
fact('First viewport', option.viewport),
|
||||
fact('The case', option.case),
|
||||
fact('Kept', option.kept),
|
||||
fact('Risk', option.risk),
|
||||
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
|
||||
].filter(Boolean).join('\n ');
|
||||
@@ -353,33 +449,40 @@ function page() {
|
||||
<figcaption>inspiration</figcaption>
|
||||
</figure>` : '';
|
||||
const details = hasBack(option) ? flipChip('Details') : '';
|
||||
if (option.sketchSrc) {
|
||||
// Thumb-only art renders inside the body via anatomy(), never as a face,
|
||||
// and a declined card's sketch slot is ignored outright.
|
||||
if (thumbOnly(option)) return '';
|
||||
if (faceSketch(option)) {
|
||||
return `<div class="media sketching" data-sketch="${esc(option.sketchSrc)}">
|
||||
<div class="shimmer"><span class="sketch-note">sketching…</span></div>
|
||||
<div class="shimmer"><span class="sketch-note">rendering…</span></div>
|
||||
<img class="sketch" alt="" hidden>
|
||||
${inspiration}
|
||||
<div class="chips">${expandChip}${details}</div>
|
||||
</div>`;
|
||||
}
|
||||
if (option.heroSrc || option.boardSrc) {
|
||||
return `<div class="media">
|
||||
// Without a sketch the catalog art is the card's face; it stays a
|
||||
// labeled reference so it never reads as the promise of the build.
|
||||
return `<div class="media" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||
<p class="media-label">inspiration</p>
|
||||
<div class="chips">${expandChip}${details}</div>
|
||||
</div>`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this';
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card${option.isCanon ? ' canon' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : demoted(option) ? '<span class="kicker declined-k">Declined</span>' : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
|
||||
${media(option)}
|
||||
<div class="body">
|
||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||
<h2>${esc(option.label)}</h2>
|
||||
${anatomy(option)}
|
||||
<button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
|
||||
<button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
|
||||
</div>
|
||||
</div>
|
||||
${hasBack(option) ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
@@ -390,7 +493,7 @@ function page() {
|
||||
<div class="body back-body">
|
||||
${option.boardSrc ? `<p class="tier">The full read · ${esc(option.label)}</p>` : ''}
|
||||
${backFacts(option)}
|
||||
<button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
|
||||
<button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
@@ -481,6 +584,10 @@ function page() {
|
||||
.nav.next { right: auto; left: 50%; top: auto; bottom: 6px; transform: translate(-50%, 0); }
|
||||
.fade-prev { top: 0; left: 0; right: 0; bottom: auto; width: auto; height: 72px; background: linear-gradient(180deg, var(--ks-lacquer), transparent); }
|
||||
.fade-next { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: 72px; background: linear-gradient(0deg, var(--ks-lacquer), transparent); }
|
||||
/* In the vertical deck the cross axis is horizontal: flex-start would
|
||||
shrink a declined card to content WIDTH, not height, so it stretches
|
||||
like every other card and its height is already its own. */
|
||||
.grid > .card.declined { align-self: stretch; }
|
||||
}
|
||||
.card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
|
||||
.card:hover { transform: rotate(0deg) translateY(-4px); }
|
||||
@@ -547,6 +654,17 @@ function page() {
|
||||
.pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 3px 0 4px; background: oklch(7% 0.006 95 / 0.72); backdrop-filter: blur(3px); }
|
||||
.pip:hover { left: 0; bottom: 0; width: 100%; height: 100%; border-radius: 0; z-index: 3; }
|
||||
.sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||
/* Catalog art standing in for a sketchless card is a reference, and says so
|
||||
on its face; the same pill later carries "artwork unavailable". */
|
||||
.media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); padding: 3px 8px 4px; background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 4px; backdrop-filter: blur(3px); }
|
||||
/* Art that never arrives collapses to the card's own palette (painted
|
||||
inline from its swatches) instead of sitting as a dark void wearing a
|
||||
zoom cursor; the scrim keeps the label legible over saturated fields,
|
||||
passes clicks through, and the flip chips stay above it. A card with no
|
||||
palette falls back to the quiet graphite field. */
|
||||
.media.unavailable { background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
|
||||
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
|
||||
.media.unavailable .chips { z-index: 2; }
|
||||
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
||||
the real sketch whenever it lands. */
|
||||
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
||||
@@ -558,6 +676,42 @@ function page() {
|
||||
/* The generic .media img display:block would defeat [hidden] and float an
|
||||
empty block over the shimmer; an unloaded sketch must truly not render. */
|
||||
.media img[hidden] { display: none; }
|
||||
/* Declined challengers: the weighing demoted them, so the card is narrower
|
||||
and quieter, its catalog art rides as a labeled thumb in the body, and
|
||||
the action reads "Adopt anyway". Adoptable, never deleted: the demoted
|
||||
row is the hand's proof of judgment. */
|
||||
/* Narrow AND short: without align-self the stretch default drags a thin
|
||||
declined card to the tallest contender's height, a strange stilt of a
|
||||
card beside the full hand. */
|
||||
.grid > .card.declined { flex: 0 0 clamp(15rem, 21vw, 21rem); align-self: flex-start; }
|
||||
.card.declined .face { background: var(--ks-graphite); }
|
||||
.card.declined:hover .face { border-color: var(--ks-text-faint); }
|
||||
.card.declined h2 { font-size: 1rem; color: var(--ks-text); }
|
||||
.kicker.declined-k { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); }
|
||||
.card.declined button.choose { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font-size: .85rem; padding: 8px 22px; }
|
||||
.card.declined button.choose:hover { background: var(--ks-graphite-2); border-color: var(--ks-text-muted); }
|
||||
/* Thumb-scale inspiration: present, labeled, zoomable, and incapable of
|
||||
outshouting a text-only assigned card. */
|
||||
.inspo { position: relative; flex: none; margin: 2px 0; width: 104px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; cursor: zoom-in; background: var(--ks-lacquer); }
|
||||
.inspo img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.inspo figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .48rem; letter-spacing: .16em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 2px 0 3px; background: oklch(7% 0.006 95 / 0.72); }
|
||||
/* Raises: the donations the assigned direction took from the hand it beat,
|
||||
each named for its donor. Patina, not kinpaku: a raise is provenance. */
|
||||
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; }
|
||||
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; border-left: 2px solid var(--ks-patina); padding-left: 8px; }
|
||||
.raise .fact-label { color: var(--ks-patina); }
|
||||
/* Several raises cycle instead of stacking: one visible at a time, a
|
||||
counter for the rest, the whole block advances on click. */
|
||||
.raises-cycle { cursor: pointer; border-radius: 6px; }
|
||||
.raises-cycle .raise { display: none; border-left: none; padding-left: 0; }
|
||||
.raises-cycle .raise.active { display: block; }
|
||||
.raises-cycle { border-left: 2px solid var(--ks-patina); padding-left: 8px; }
|
||||
.raises-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.raises-head .fact-label { color: var(--ks-patina); }
|
||||
.raises-count { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .14em; color: var(--ks-text-faint); }
|
||||
.raises-count::after { content: " \\203A"; }
|
||||
.raises-cycle:hover .raises-count { color: var(--ks-patina); }
|
||||
.sr-live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
/* The standing exit as a card: present with full anatomy, never dressed as a
|
||||
contender. Graphite instead of kinpaku, and it never takes the lead ring. */
|
||||
.card.canon .face { border-color: var(--ks-rule); background: var(--ks-graphite); }
|
||||
@@ -570,9 +724,14 @@ function page() {
|
||||
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
||||
#steer { flex: 1; min-width: 16rem; background: var(--ks-lacquer-raised); color: var(--ks-text); border: 1px solid var(--ks-rule); border-radius: 7px; padding: .6rem .85rem; font: inherit; }
|
||||
#steer:focus { outline: none; border-color: var(--ks-patina); }
|
||||
#reroll { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
#reroll svg { width: 15px; height: 15px; }
|
||||
.reroll-btn { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
.reroll-btn:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
.reroll-btn svg { width: 15px; height: 15px; }
|
||||
.reroll-btn[disabled] { opacity: .4; cursor: default; }
|
||||
/* The register steers read quieter than the plain roll: they are exits from
|
||||
the current register, not the round's main verbs. */
|
||||
#reroll-safer, #reroll-bolder { color: var(--ks-text-muted); min-height: 38px; }
|
||||
#reroll-safer:hover, #reroll-bolder:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
|
||||
/* The quiet exit: always available, never argued with, visually subordinate
|
||||
to the dealt cards and the re-roll so it reads as the user's own door,
|
||||
not a recommendation. */
|
||||
@@ -617,16 +776,33 @@ function page() {
|
||||
</main>
|
||||
<footer>
|
||||
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
|
||||
${payload.reroll ? '<button id="reroll"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg><span>Re-roll</span></button>' : ''}
|
||||
${(() => {
|
||||
if (!payload.reroll) return '';
|
||||
const die = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
|
||||
const registers = Array.isArray(payload.reroll.registers) ? payload.reroll.registers.filter((r) => r === 'safer' || r === 'bolder') : [];
|
||||
// The registers are the user's steering wheel on the familiar-to-bold
|
||||
// axis; the plain re-roll sits between them so the spatial order matches
|
||||
// the axis it names.
|
||||
const safer = registers.includes('safer') ? '<button class="reroll-btn" id="reroll-safer" title="Deal the familiar register: conventional grounded directions plus the category standard against named competitors"><span>← Safer hand</span></button>' : '';
|
||||
const bolder = registers.includes('bolder') ? '<button class="reroll-btn" id="reroll-bolder" title="Deal foreign forms only, at full commitment"><span>Bolder hand →</span></button>' : '';
|
||||
return `${safer}<button class="reroll-btn" id="reroll">${die}<span>Re-roll</span></button>${bolder}`;
|
||||
})()}
|
||||
${payload.canon && !payload.canonCard ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
|
||||
</footer>
|
||||
<script>
|
||||
const steer = () => document.getElementById('steer')?.value || '';
|
||||
// A followup round's pick keeps the tab: the next round arrives via
|
||||
// --update, so the page shows the loading hand instead of goodbye. Detached
|
||||
// mode only, and the page must agree with the server: a blocking server
|
||||
// exits on any pick and has no update channel, so a followup payload there
|
||||
// still gets the goodbye screen, never a loading hand nothing will resolve.
|
||||
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
|
||||
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
|
||||
beat();
|
||||
setInterval(beat, 5000);
|
||||
async function answer(optionId) {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
|
||||
if (FOLLOWUP) { await awaitNextRound(); return; }
|
||||
document.body.innerHTML = '<div class="done"><svg viewBox="0 0 24 24" width="38" height="38" fill="oklch(84% 0.19 80.46)" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>Choice recorded. The agent is resuming; you can close this tab.</div>';
|
||||
}
|
||||
document.querySelectorAll('button.choose').forEach(b => b.addEventListener('click', () => answer(b.dataset.id)));
|
||||
@@ -635,6 +811,25 @@ function page() {
|
||||
b.closest('.card').classList.toggle('flipped');
|
||||
}));
|
||||
|
||||
// Raise cycler: click (or Enter) advances to the next donation.
|
||||
document.querySelectorAll('.raises-cycle').forEach(cycle => {
|
||||
const raises = [...cycle.querySelectorAll('.raise')];
|
||||
const count = cycle.querySelector('[data-raises-count]');
|
||||
let at = 0;
|
||||
const live = cycle.querySelector('.sr-live');
|
||||
const show = (announce) => {
|
||||
raises.forEach((raise, i) => raise.classList.toggle('active', i === at));
|
||||
if (count) count.textContent = (at + 1) + '/' + raises.length;
|
||||
// Screen readers hear the raise they just advanced to; the initial
|
||||
// render stays quiet so page load does not narrate every card.
|
||||
if (announce && live) live.textContent = 'Raise ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');
|
||||
};
|
||||
show(false);
|
||||
const advance = (e) => { e.stopPropagation(); at = (at + 1) % raises.length; show(true); };
|
||||
cycle.addEventListener('click', advance);
|
||||
cycle.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); advance(e); } });
|
||||
});
|
||||
|
||||
// Deal from the stack: cards begin piled at the grid's center, blurred,
|
||||
// then travel to their seats with a stagger.
|
||||
const cards = [...document.querySelectorAll('.card')];
|
||||
@@ -682,7 +877,7 @@ function page() {
|
||||
const note = m.querySelector('.sketch-note');
|
||||
const started = Date.now();
|
||||
// A live elapsed count is the difference between "working" and "frozen".
|
||||
const tick = setInterval(() => { if (note) note.textContent = 'sketching · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
|
||||
const tick = setInterval(() => { if (note) note.textContent = 'rendering · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
|
||||
const settle = () => { clearInterval(tick); m.classList.remove('sketching', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
|
||||
const standIn = () => {
|
||||
const pip = m.querySelector('.pip img');
|
||||
@@ -693,7 +888,7 @@ function page() {
|
||||
clearInterval(tick);
|
||||
const label = document.createElement('p');
|
||||
label.className = 'stand-in-label';
|
||||
label.textContent = 'inspiration · sketch pending';
|
||||
label.textContent = 'inspiration · comp pending';
|
||||
m.appendChild(label);
|
||||
};
|
||||
const tryLoad = () => {
|
||||
@@ -709,8 +904,38 @@ function page() {
|
||||
tryLoad();
|
||||
});
|
||||
|
||||
// Inspiration PIP opens the full catalog card in the lightbox.
|
||||
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
|
||||
// A declared image that never loads (missing catalog asset, offline shell)
|
||||
// must not sit as a dark void: the slot collapses to the card's own
|
||||
// palette, labeled honestly, and the card competes on its facts. Sketch
|
||||
// slots are excluded; their polling owns the wait.
|
||||
const artFailed = (img) => {
|
||||
const m = img.closest('.media');
|
||||
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
|
||||
m.classList.add('unavailable');
|
||||
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
|
||||
if (colors.length) m.style.background = 'linear-gradient(135deg, ' + colors.map((c, i) => c + ' ' + Math.round(i * 100 / colors.length) + '% ' + Math.round((i + 1) * 100 / colors.length) + '%').join(', ') + ')';
|
||||
m.querySelector('.media-label')?.remove();
|
||||
m.querySelector('.chip.expand')?.remove();
|
||||
m.removeAttribute('title');
|
||||
img.remove();
|
||||
const label = document.createElement('p');
|
||||
label.className = 'media-label';
|
||||
label.textContent = 'artwork unavailable';
|
||||
m.appendChild(label);
|
||||
};
|
||||
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
|
||||
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
||||
else img.addEventListener('error', () => artFailed(img), { once: true });
|
||||
});
|
||||
// A broken inspiration PIP or thumb just leaves; nothing depends on it.
|
||||
document.querySelectorAll('.pip img, .inspo img').forEach(img => {
|
||||
const gone = () => img.closest('.pip, .inspo')?.remove();
|
||||
if (img.complete && img.naturalWidth === 0) gone();
|
||||
else img.addEventListener('error', gone, { once: true });
|
||||
});
|
||||
|
||||
// Inspiration PIP or body thumb opens the full catalog card in the lightbox.
|
||||
document.querySelectorAll('.pip, .inspo').forEach(p => p.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const img = p.querySelector('img');
|
||||
if (!img) return;
|
||||
@@ -757,7 +982,7 @@ function page() {
|
||||
const ambient = document.getElementById('ambient');
|
||||
document.querySelectorAll('.card').forEach(card => {
|
||||
card.addEventListener('mouseenter', () => {
|
||||
const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img');
|
||||
const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img') || card.querySelector('.face.front .inspo img');
|
||||
if (!art || !art.getAttribute('src')) return;
|
||||
ambient.style.backgroundImage = 'url("' + art.getAttribute('src') + '")'; ambient.style.opacity = '1';
|
||||
});
|
||||
@@ -805,8 +1030,11 @@ function page() {
|
||||
lightbox.addEventListener('click', closeLightbox);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
||||
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
|
||||
document.getElementById('reroll')?.addEventListener('click', async () => {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
|
||||
const dealAgain = async (register) => {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
|
||||
await awaitNextRound();
|
||||
};
|
||||
async function awaitNextRound() {
|
||||
const grid = document.querySelector('.grid');
|
||||
const cardsNow = [...grid.querySelectorAll('.card')];
|
||||
const g = grid.getBoundingClientRect();
|
||||
@@ -823,14 +1051,17 @@ function page() {
|
||||
}
|
||||
const cardHeight = cardsNow[0] ? cardsNow[0].getBoundingClientRect().height : 0;
|
||||
grid.innerHTML = cardsNow.map(() => '<article class="card skeleton"' + (cardHeight ? ' style="height:' + cardHeight + 'px"' : '') + '><div class="card-inner"><div class="face front"><div class="media"><div class="shimmer"></div></div><div class="body"><div class="line tier w40"></div><div class="line title w70"></div><div class="line w90"></div><div class="line w80"></div><div class="line w60"></div><div class="line button"></div></div></div></div></article>').join('');
|
||||
document.getElementById('reroll')?.setAttribute('disabled', '');
|
||||
document.querySelectorAll('.reroll-btn').forEach(b => b.setAttribute('disabled', ''));
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const status = await (await fetch('/next-status')).json();
|
||||
if (status.ready) { clearInterval(poll); location.reload(); }
|
||||
} catch { /* server briefly busy */ }
|
||||
}, 1200);
|
||||
});
|
||||
}
|
||||
document.getElementById('reroll')?.addEventListener('click', () => dealAgain());
|
||||
document.getElementById('reroll-safer')?.addEventListener('click', () => dealAgain('safer'));
|
||||
document.getElementById('reroll-bolder')?.addEventListener('click', () => dealAgain('bolder'));
|
||||
</script>`;
|
||||
}
|
||||
|
||||
@@ -887,22 +1118,29 @@ const server = http.createServer((req, res) => {
|
||||
let parsed = {};
|
||||
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
|
||||
const chosen = options.find((o) => o.id === parsed.optionId);
|
||||
const isReroll = parsed.optionId === 'reroll';
|
||||
// A followup round's pick is not terminal: the table stays open for the
|
||||
// next round (--update), exactly like a re-roll. Detached mode only;
|
||||
// the blocking mode has no update channel, so its picks stay terminal.
|
||||
const followupOpen = Boolean(detachedKey) && payload.followup === true && !isReroll;
|
||||
const answer = JSON.stringify({
|
||||
optionId: parsed.optionId ?? null,
|
||||
steer: parsed.steer ?? '',
|
||||
...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}),
|
||||
...(followupOpen ? { followup: true } : {}),
|
||||
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||
...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
|
||||
});
|
||||
const isReroll = parsed.optionId === 'reroll';
|
||||
if (detachedKey) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
|
||||
} else {
|
||||
printAnswer(answer);
|
||||
}
|
||||
// A re-roll in detached mode keeps the table open: the client shows a
|
||||
// loading hand and reloads when --update delivers the next round.
|
||||
if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150);
|
||||
// A re-roll or followup pick in detached mode keeps the table open: the
|
||||
// client shows a loading hand and reloads when --update delivers the
|
||||
// next round.
|
||||
if (!((isReroll || followupOpen) && detachedKey)) setTimeout(() => process.exit(0), 150);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -920,8 +1158,7 @@ server.listen(portArg, '127.0.0.1', () => {
|
||||
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
|
||||
}
|
||||
if (!hasFlag('no-open')) {
|
||||
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
||||
try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ }
|
||||
openSystemBrowser(url);
|
||||
}
|
||||
if (timeoutSec > 0) {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -16,9 +16,9 @@ Your job is production cleanup, not new art direction. Work only from the approv
|
||||
|
||||
Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster.
|
||||
|
||||
## Decision Sketches
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one sketch: one card, one file, written to the card's declared `sketch` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a sketch is reported back, not padded from imagination. Render through the parent's shared frame, including its aspect: the requested surface's first viewport as a flat, matte design sketch in the card's own palette and type character, deliberately unfinished, no photorealism, no gloss; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. The frame is shared across siblings so no sketch looks more finished than another; a finish gap breaks the comparison. The only legible text is the product's real name and one real headline; greek every other text region into indistinct lines, because an invented spec, price, or date in a sketch is a claim PRODUCT.md never made. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a sketch run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
|
||||
@@ -16,12 +16,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
|
||||
|
||||
## Input Contract
|
||||
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); desktop and mobile screenshot paths captured by the parent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and the approved comp path; and the skill's `reference/craft-floor.md` path. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, which live in `.impeccable/review/` (on the web, `desktop.png` and `mobile.png`; on 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, and `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and, on a comp-led build, the approved comp path (a code-led build has no approved comp; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing in this file 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 also carries 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 and judge every check in the platform's own conventions, the screenshots are device captures rather than browser viewports, and your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
|
||||
|
||||
## Checks, in order
|
||||
|
||||
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
|
||||
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <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. 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, and that is a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and they 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, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. A critique-reference comp, when one arrived on such a build, is provocation rather than spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is the question of what the image dared that the build did not, and the 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. In every material_fixes list, 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 and that is a material fix ahead of any craft point. Then, for each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport.
|
||||
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
|
||||
@@ -39,4 +39,4 @@ Return the disposition line first, then exactly five sections: `persistence` (pa
|
||||
|
||||
## Verdict Pass
|
||||
|
||||
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
|
||||
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent recaptures over the same screenshot files you read in the review round, so re-read those exact paths for this round; a round-stamped filename you invent points at nothing. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
|
||||
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 5,
|
||||
"statusMessage": "Checking UI changes"
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
|
||||
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Design deep pass"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,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.0.4
|
||||
user-invocable: true
|
||||
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
|
||||
argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
|
||||
license: Apache 2.0
|
||||
allowed-tools:
|
||||
- Bash(npx impeccable *)
|
||||
@@ -15,11 +15,11 @@ This skill gives you the tools and permission to create design that earns to be
|
||||
Core principles:
|
||||
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
|
||||
- Dream big and bold. Distinct, beautiful, outstanding and highly inspiring work.
|
||||
- Verify in bounded passes, not a loop, and the ceiling covers the whole cycle: screenshots, defect scans, micro-edits, and rebuilds alike. Build fully, inspect once with a batched round (desktop and mobile together), fix everything it shows in one batch, confirm with at most one more round, and stop polishing. Open-ended self-QA burns the user's money doing worse what the finish handoffs do better.
|
||||
- Verify in bounded passes, not a loop, and the ceiling covers the whole cycle: screenshots, defect scans, micro-edits, and rebuilds alike. Build fully, inspect once with a batched round (desktop and mobile together on the web; the shipped device classes on a native platform), fix everything it shows in one batch, confirm with at most one more round, and stop polishing. Open-ended self-QA burns the user's money doing worse what the finish handoffs do better.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session (if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs`; keep cwd at the user's project). 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.
|
||||
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 .claude/skills/impeccable/scripts/...` command in this skill and its references, and `.claude/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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -38,3 +38,9 @@ Would a fluent Android user trust this app, or trip on off-spec components? The
|
||||
- **One FAB, one primary action.** Never stack FABs or spend one on a secondary task.
|
||||
- **Snackbars for transient feedback** (actionable when useful, never a toast for that); dialogs only for decisions that must interrupt.
|
||||
- **Material motion patterns.** Container transform, shared-axis, fade-through, with standard easing and durations; honor the system Remove animations setting with a crossfade or instant cut.
|
||||
|
||||
## Verifying the build
|
||||
|
||||
- **Screenshots come from the emulator or a connected device, never a browser.** Build and install, then capture with `adb exec-out screencap -p > <path>` (pick a device with `adb -s <serial>` when several are attached). Capture every device class the app ships to, at least one phone and, when tablets are a target, one tablet, and write the files where the review flow expects them.
|
||||
- **Dark theme and font scale belong in the pass.** `adb shell cmd uimode night yes` flips the theme; `adb shell settings put system font_scale 1.3` (restore `1.0` after) catches the clipped labels a fixed layout hides; with several targets attached, the capture's `-s <serial>` goes on these commands too.
|
||||
- **Emulators give breadth; gestures, refresh rates, and performance need hardware.** Say which one produced the evidence.
|
||||
|
||||
@@ -74,12 +74,15 @@ Keep content visible in the default state so failed scripts do not hide the page
|
||||
|
||||
Respect autoplay and sound preferences. Any nonessential loop must stop when offscreen or hidden.
|
||||
|
||||
Every web animation needs a `prefers-reduced-motion` path with an intentional alternative. Remove or reduce spatial movement while preserving opacity, color, and state transitions that carry meaning. Reduced motion means fewer and gentler animations, not disabling all motion; feedback that confirms an action should remain legible.
|
||||
|
||||
## Verify
|
||||
|
||||
- The focal motion is specific to the selected world and surface.
|
||||
- Every supporting animation explains feedback, state, or relationship.
|
||||
- Interruption and repeated use behave correctly.
|
||||
- Desktop, mobile, and keyboard paths remain usable.
|
||||
- The `prefers-reduced-motion` path reduces movement without erasing meaningful feedback or state changes.
|
||||
- Expensive effects stay smooth on the target device.
|
||||
- Removing an animation would lose meaning or authored character, not merely decoration.
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
> **Additional context needed**: which section is the target, and what must stay untouched.
|
||||
|
||||
An open direction round owns the word first: "bolder" said while a direction decision is on the table is the Bolder hand register steer, a fresh deal of foreign forms (see new-work.md), not this command. This command refines a surface whose world already shipped.
|
||||
|
||||
"Bolder" is an amplification request, and almost always it is scoped to something that already exists. The surrounding page, its system, and its conventions are the given. Your job is to raise one part to the conviction the rest already implies, without rebuilding anything the brief did not name. The reflex answer, reaching for more effects, is the opposite of bold; reject it first.
|
||||
|
||||
## Scope is sovereign
|
||||
|
||||
@@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
|
||||
- **Type:** body measure 65–75ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
|
||||
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
|
||||
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
|
||||
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
|
||||
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
|
||||
- **Coverage:** every brief requirement present and findable within seconds.
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ Your job is production cleanup, not new art direction. Work only from the approv
|
||||
|
||||
Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster.
|
||||
|
||||
## Decision Sketches
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one sketch: one card, one file, written to the card's declared `sketch` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a sketch is reported back, not padded from imagination. Render through the parent's shared frame, including its aspect: the requested surface's first viewport as a flat, matte design sketch in the card's own palette and type character, deliberately unfinished, no photorealism, no gloss; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. The frame is shared across siblings so no sketch looks more finished than another; a finish gap breaks the comparison. The only legible text is the product's real name and one real headline; greek every other text region into indistinct lines, because an invented spec, price, or date in a sketch is a claim PRODUCT.md never made. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a sketch run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
|
||||
|
||||
## Input Contract
|
||||
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); desktop and mobile screenshot paths captured by the parent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and the approved comp path; and the skill's `reference/craft-floor.md` path. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, which live in `.impeccable/review/` (on the web, `desktop.png` and `mobile.png`; on 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, and `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and, on a comp-led build, the approved comp path (a code-led build has no approved comp; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing in this file 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 also carries 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 and judge every check in the platform's own conventions, the screenshots are device captures rather than browser viewports, and your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
|
||||
|
||||
## Checks, in order
|
||||
|
||||
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
|
||||
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <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. 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, and that is a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and they 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, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. A critique-reference comp, when one arrived on such a build, is provocation rather than spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is the question of what the image dared that the build did not, and the 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. In every material_fixes list, 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 and that is a material fix ahead of any craft point. Then, for each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport.
|
||||
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
|
||||
@@ -34,4 +34,4 @@ Return the disposition line first, then exactly five sections: `persistence` (pa
|
||||
|
||||
## Verdict Pass
|
||||
|
||||
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
|
||||
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent recaptures over the same screenshot files you read in the review round, so re-read those exact paths for this round; a round-stamped filename you invent points at nothing. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
|
||||
@@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`.
|
||||
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
|
||||
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
|
||||
|
||||
## Intentional findings
|
||||
## Triage findings
|
||||
|
||||
The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`.
|
||||
The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes:
|
||||
|
||||
- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through.
|
||||
- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `"<who decided: evidence>"`; write "user confirmed" only when the user actually did.
|
||||
- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit.
|
||||
|
||||
Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first.
|
||||
|
||||
Prefer the narrowest exception:
|
||||
|
||||
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding line shows an `ignore-value <rule> <value>` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default.
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value <id> "*" --file <path>`. Run `npx impeccable detect <path>` first to see what actually fires there.
|
||||
- Reach for `ignore-file <path>` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
@@ -67,10 +73,10 @@ Example value-specific exception:
|
||||
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
|
||||
```
|
||||
|
||||
Example intentional motion exception:
|
||||
Example self-served exception, with the evidence named:
|
||||
|
||||
```bash
|
||||
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
|
||||
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject"
|
||||
```
|
||||
|
||||
Example whole-rule font exception:
|
||||
|
||||
@@ -43,3 +43,9 @@ Would a fluent iPhone user trust this app, or pause at off-spec controls? The te
|
||||
|
||||
- **System transitions.** Push slides, sheets rise, dismiss reverses the entrance. Custom transitions that fight the navigation model disorient.
|
||||
- **Honor Reduce Motion.** Crossfade instead of parallax and large slides.
|
||||
|
||||
## Verifying the build
|
||||
|
||||
- **Screenshots come from the Simulator, never a browser.** Build and run, then capture with `xcrun simctl io booted screenshot <path>` (with several running, replace `booted` with the target's UDID from `xcrun simctl list devices booted`; display names can collide, the UDID never does). Capture every device class the app ships to, at least one iPhone and, when iPad is a target, one iPad, and write the files where the review flow expects them.
|
||||
- **Dark Mode and Dynamic Type belong in the pass.** `xcrun simctl ui booted appearance dark` flips appearance, reusing the capture's UDID when several are booted; a check at a large Dynamic Type size catches the truncation a fixed layout hides.
|
||||
- **Simulators give breadth; posture, gestures, and performance need hardware.** Say which one produced the evidence.
|
||||
|
||||
@@ -43,12 +43,14 @@ The script assigns which structure gets built; your top-ranked structure is what
|
||||
1. Name the product's unique mechanism in one sentence, the audience's real scene, its cultural home, and what this first surface must prove. Note the page this category always ships and its predictable opposite; name both as the rut and keep them out of the seven-candidate list. A brief that paints its own picture, a product name, a titled artifact, a governing metaphor, adds its literal reading to the rut: spend at most one candidate on it and derive the rest from elsewhere in the audience's world.
|
||||
2. From that cultural world, list seven concrete visual systems, artifacts, places, or rituals the audience knows by heart, each with one line on why it resonates and can carry the mechanism, ordered by resonance. The audience's world includes its graphic and screen traditions, not only its physical objects: the notation, publications, identity programs, data graphics, and interfaces it reads daily; a nameable abstract system (a school of poster, a documentation standard) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; dig until the list spans at least three families.
|
||||
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
|
||||
4. Run `node .claude/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: 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, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and 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, and beating a thin or tool-monoculture list is the point.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the hand's challengers as named alternates, the weighing's verdict written on each as its one-line case, an honest "fuses poorly because X" included; the weighing informs the user's choice, it never pre-empts it. A hand holds at most three challengers: when the roll deals more, the three strongest join the hand 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 re-roll with an optional one-line steer. Never present a ranked menu of your own grounded candidates; a lineup of those invites the safest card. 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; the structured tool's option list also carries the standing exit as its last option.
|
||||
4. Run `node .claude/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: 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, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and 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, and beating a thin or tool-monoculture list is the point. The weighing closes with a verdict per challenger, decided before any borrowing is considered: wins (beats the assigned direction on both axes; it becomes the build candidate), competitive (holds one axis; it 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 motif lifted from a declined world 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, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand 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 MY PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, 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: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A 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; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
|
||||
|
||||
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, in 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. A standing preference gets recorded 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. You may 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, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; 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 (run the script with `--schema` for the exact shape); the page renders identity from these fields, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now 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. Exit 4 means the page was closed without an answer: 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 instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as 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, in 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. A standing preference gets recorded 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. You may 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 and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `followup: true` when the execution-contract round will follow (it does whenever image generation exists and no standing build-path preference is recorded); 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 (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now 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. Exit 4 means the page was closed without an answer: 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 instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
|
||||
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, the canon card included. Serve the page first, then produce the sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
|
||||
When image generation exists, every card also declares a `sketch` path under `.impeccable/mocks/decision/` (the field keeps its wire name for compatibility; what it carries is the card's comp), 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, produced under the comp discipline in [visualize.md](visualize.md): 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 sketch pays sketch quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, 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. When the harness runs subagents in parallel, fan the set out as 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 at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading 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: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the 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.
|
||||
|
||||
The moment the direction lands, one more round on the same open table decides the execution contract. The direction payload declares `followup: true`, so the table stays open after the pick; deliver the build-path payload through `--update` immediately. Two text-only cards. **Comp-led**: a first-viewport comp is generated and it is law, the finish review audits the build against it; boldest composition on the table, fix rounds expected, motion at risk; choosing it makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. Lead with the chosen world's fit: a costume-heavy catalog world leads comp-led, a quiet or conventional direction leads code-led; the lead is a default, never a decision, and the user flips it freely. A standing preference, voiced once, is recorded as a brand commitment in PRODUCT.md and skips this round on later surfaces. Without image generation there is no fork and no round: code-led is the only path, stated in one line rather than asked. Only a detached table (`--start`) stays open for `--update`: a blocking serve or the structured-tool channel runs the build-path round as its own second question instead, and `followup: true` belongs only on a detached round.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
|
||||
|
||||
@@ -80,15 +82,18 @@ If the work establishes durable strategy for a route or artifact, read its exist
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
Whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options rendered and put before the user for approval. This step is proven to produce the most compositional and ambitious work.
|
||||
On a comp-led build, whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options put before the user for approval, the chosen card's decision comp plus two variations. This step is proven to produce the most compositional and ambitious work. On a code-led build the comp round is skipped by contract, never by drift: the ambition it would have carried lives in the direction contract's FIRST VIEWPORT block and named signature interaction, and the finish reviewer audits those promises in behavior.
|
||||
|
||||
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
|
||||
|
||||
## 6. Build with full commitment
|
||||
|
||||
When an approved comp exists, the comp is king, and the build happens in phases. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses.
|
||||
|
||||
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
|
||||
|
||||
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
|
||||
- **Prove the hero before building past it.** When an approved comp exists, render the first viewport, capture it, and set it beside the comp's first viewport before any later section: the hero carries the run's ambition, and every following section inherits its shortfall. Judge scale and density as quantities, a field at a tenth of the comp's coverage or type at half its weight is a different design, and a five-minute retry here is what a rebuild verdict at the finish costs when this check is skipped.
|
||||
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
|
||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
|
||||
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
|
||||
@@ -100,8 +105,8 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
||||
|
||||
## 7. Inspect and finish
|
||||
|
||||
Inspect desktop and mobile in one batched screenshot round, critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
|
||||
Inspect the surface's target sizes in one batched screenshot round: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes per OS, captured from the simulator or emulator the way the platform reference's Verifying the build section describes. Critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
|
||||
|
||||
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. Where this harness runs no design hook, run `node .claude/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch: put that verdict in front of the user with the named comp regions and let them choose between a re-derivation and shipping as it stands. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
|
||||
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. On the web, where this harness runs no design hook, run `node .claude/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless web build that skips this ships every tell the hook exists to catch. A native platform skips the detector entirely: it reads HTML and CSS and has no verdict on native code, so the reviewer's floor check is the only slop gate and the input packet says so. Capture the screenshots into `.impeccable/review/`, one file per captured viewport (on the web, `desktop.png` and `mobile.png`; on native, one per device class, such as `phone.png` and `tablet.png`, suffixed per OS on adaptive), creating that directory when the harness does not; the paths you pass the reviewer are its spec, and that directory is where it looks when a passed path is missing. Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths (on a code-led build there is no approved comp; the chosen decision comp rides in that slot as the critique reference, named as such), the craft-floor reference path, and on a native platform the platform reference path(s), [ios.md](ios.md) / [android.md](android.md), both on adaptive, plus one line saying no detector ran, so the reviewer judges in the platform's conventions rather than the web's. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a verdict, telling the user what is happening rather than asking permission to fix a failure. The user is consulted only when a second rebuild directive arrives, both verdicts on the table, or when rebuilding would discard content the user approved. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports over the same files. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
|
||||
|
||||
Then spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, the artifact path, the direction contract, PRODUCT.md, the [document.md](document.md) reference path, and the boundary to write at; it records DESIGN.md and the sidecar from the built world, ground truth over intention; without subagents the pass runs from [degraded/documenter.md](degraded/documenter.md). A clean detector pass is not finished; finished is the contract kept, the comp honored, the review closed, and the system recorded.
|
||||
|
||||
@@ -19,7 +19,7 @@ Fix the cause at the narrowest correct level. Ask when a binding system principl
|
||||
|
||||
## 2. Gather the evidence
|
||||
|
||||
Use the feature yourself at representative desktop and mobile sizes. Determine:
|
||||
Use the feature yourself at the surface's representative sizes: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes on the simulator, emulator, or hardware, captured per the platform reference's Verifying the build section. Determine:
|
||||
|
||||
- whether the path is functionally complete;
|
||||
- the intended quality bar and time available;
|
||||
@@ -86,10 +86,10 @@ Do not perfect one corner while leaving the rest below the same quality bar.
|
||||
|
||||
Walk the complete path again with mouse, keyboard, and touch where applicable. Check:
|
||||
|
||||
- mobile, intermediate, and wide layouts;
|
||||
- mobile, intermediate, and wide layouts on the web; phone and tablet size classes in both supported orientations on native;
|
||||
- loading, empty, error, success, disabled, long-content, and missing-content states;
|
||||
- zoom, contrast, focus, semantics, and screen-reader names;
|
||||
- console errors, layout shift, interaction latency, image loading, and supported browsers;
|
||||
- console errors, layout shift, interaction latency, and image loading everywhere; supported browsers on the web; supported OS versions, runtime warnings, and dropped frames on native;
|
||||
- agreement with DESIGN.md, neighboring features, and the user's scope.
|
||||
|
||||
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Visualize: Direction Comps & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate three compositional options
|
||||
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. A decision-page sketch is not a probe: it chose the direction at deliberately unfinished fidelity, so the three comps render regardless, and the chosen card's sketch seeds at most one of them.
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here.
|
||||
|
||||
- A comp is a designed surface, not a picture of the subject. Lead the generation prompt with the surface's own structure, whatever regions this design actually has, named in order with their scale relationships; a page with no navigation states that instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with the world's atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph or scene with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally.
|
||||
- When the user shortlisted multiple concepts, spread the three across them.
|
||||
@@ -22,17 +22,17 @@ Show the three together: in the harness when it can display images, otherwise on
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is still recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats a build with generated comps and no recorded approval as carrying a material finding.
|
||||
This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is still recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats a build whose comp round produced comps with no recorded approval as carrying a material finding; decision comps under `.impeccable/mocks/decision/` are the direction round's hand, not comp-round output, and imply no approval on their own.
|
||||
|
||||
After approval, record the choice where tools can find it: the approved comp's path goes in the surface brief, and the approved comp's `.json` prompt sidecar gains `"approved": true` (every comp generated through `generate-image.mjs` has one; create it if a native tool didn't). The sidecar travels with the mocks folder, so the approval survives sessions and machines that never see the brief. Then summarize the composition and the parts of the comp that must not be literalized, return to new-work.md, record the direction contract from the approved surface concept, and build.
|
||||
|
||||
## Inventory implementation fidelity
|
||||
|
||||
Before building, inventory the approved comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same written inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
|
||||
Before building, read the approved comp as a design system and record it in the brief: component grammar, corner language, line weights, elevation treatment, and the type ramp, because everything the comp does not show gets built from this record, and without it the fallback is the model's stock kit of square boxes, 1px grids, bento cells, and hard shadows. Then inventory the comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same written inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. The primary action gets its own row with its own medium: when the comp dissolves, stamps, erodes, or otherwise physically works the main CTA, that treatment is signature material on the page's most important element, and shrinking it to a border trick or a few decorative pixels is the compliance-token version of commitment. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
|
||||
|
||||
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack; writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish, is not a medium choice, it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly, diagrams with countable elements, controls, flat shape systems, and it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region instead of producing it is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity, so "no photography on hand" forbids fake proof, not an illustrated hero.
|
||||
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack, and so is any texture by that name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient or layered background is not a texture medium and "layered CSS textures" is not a medium at all. Writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish or a cotton field's weave, is not a medium choice, it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly, diagrams with countable elements, controls, flat shape systems, and it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region instead of producing it is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity, so "no photography on hand" forbids fake proof, not an illustrated hero.
|
||||
|
||||
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond, and code executed safely and professionally remains first-class there. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
|
||||
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond, and code executed safely and professionally remains first-class there. A field or texture built from many small elements carries a quantity commitment either way: write down its approximate density and coverage ("thousands of glyphs over two-thirds of the fold, dense at the top fading into the path"), because a field rebuilt at a tenth of its density passes every checklist and still is not the design. TYPE rows carry the same discipline: name the face's compression class, and render one headline word against the comp before building on it; a visibly wider or lighter silhouette means the face is wrong, and every section built on it inherits the miss. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
|
||||
|
||||
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies.
|
||||
|
||||
@@ -42,6 +42,8 @@ Treat the comp as a north star, not something to trace, and know what that allow
|
||||
|
||||
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .claude/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
|
||||
|
||||
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
|
||||
When the harness runs subagents, spawn the shipped asset producer every time, even when the inventory's produce bucket looks empty: its manifest is the independent second opinion on your media, and runs that skipped the spawn are the runs whose cotton became CSS. An honestly empty manifest costs one cheap spawn; a wrongly empty produce bucket costs the build its materials. Use the producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
|
||||
|
||||
Convert images with a converter context.mjs reported at boot (the IMAGE_TOOLS line); probe only when it reported none, at most once per session, never per image.
|
||||
|
||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||
|
||||
@@ -31,6 +31,16 @@
|
||||
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
|
||||
* fresh assigned index, challengers, and compositions. One base key therefore
|
||||
* reproduces the entire chain of rounds.
|
||||
* - REGISTER (--register safer|bolder): the user's steering on the
|
||||
* familiar-to-bold axis, applied to a re-roll round. A register changes
|
||||
* only what this round instructs, never what it dealt: the same key and
|
||||
* reroll count reproduce the same deal whatever the register, so the
|
||||
* exclusion chain never forks. bolder presents the dealt foreign forms
|
||||
* as the whole hand (first-dealt leads, dice-assigned by deal order);
|
||||
* safer spends the dealt hand unseen and presents the familiar register,
|
||||
* the model's conventional grounded candidates plus the canon against
|
||||
* named competitors, the one sanctioned lineup of the model's own list.
|
||||
* Registers are user-requested, never pre-selected by the model.
|
||||
* - RATINGS: the reviewer's approval ratings weight the challenger draw
|
||||
* (3-star doubles the odds, 1-star sits out); the approved pool itself
|
||||
* is unchanged.
|
||||
@@ -41,7 +51,9 @@
|
||||
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
|
||||
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
|
||||
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
|
||||
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --scope direction
|
||||
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1 --register bolder
|
||||
* node scripts/concept-seed.mjs --chosen <challenger-id> --kind challenger --from <key> --scope direction
|
||||
* node scripts/concept-seed.mjs --kind assigned --from <key> --scope direction
|
||||
*
|
||||
* --grain names how much of the product is in play: product, flow, view, or
|
||||
* region. A docs site, an onboarding flow, a landing page and a data table are
|
||||
@@ -62,8 +74,13 @@
|
||||
* Challenger data resolves in order: a local catalog directory (the private
|
||||
* service repo, evals, and tests set IMPECCABLE_CATALOG_DIR), then the roll
|
||||
* API at impeccable.style, then a degraded assignment-only seed when both are
|
||||
* unavailable. --chosen sends the anonymous choice ping for API-dealt rolls;
|
||||
* DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables it.
|
||||
* unavailable. The anonymous choice ping fires once per resolved attended
|
||||
* round on API-dealt rolls: --kind names which card class won (assigned,
|
||||
* pick, challenger, canon) so share metrics have a denominator, --chosen
|
||||
* carries the catalog id when a dealt challenger won, and --register rides
|
||||
* along when the round came from a steered hand. Grounded candidates' names
|
||||
* never leave the machine. DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables
|
||||
* the ping entirely.
|
||||
*
|
||||
* Env vars:
|
||||
* IMPECCABLE_CONCEPT_SEED — same as --from; for reproducible eval runs.
|
||||
@@ -172,17 +189,35 @@ function telemetryDisabled() {
|
||||
return Boolean(process.env.IMPECCABLE_NO_TELEMETRY || process.env.DO_NOT_TRACK);
|
||||
}
|
||||
|
||||
// Anonymous choice ping: records only that a dealt world was selected.
|
||||
// Anonymous choice ping: one per resolved attended direction round. kind
|
||||
// says which card class won (assigned / pick / challenger / canon), so
|
||||
// pick-share and canon-share have a denominator; chosenId rides along only
|
||||
// when a dealt catalog world won, and register only when the round came from
|
||||
// a steered hand. Grounded candidates' names never leave the machine: they
|
||||
// are derived from the user's project, so the ping carries the kind alone.
|
||||
// Fire-and-forget; never fails the caller.
|
||||
export async function pingChosen({ chosenId, key, scope, mode }) {
|
||||
if (telemetryDisabled() || !chosenId) return false;
|
||||
const PING_KINDS = new Set(['assigned', 'pick', 'challenger', 'canon']);
|
||||
export async function pingChosen({ chosenId, key, scope, mode, kind, register }) {
|
||||
if (telemetryDisabled()) return false;
|
||||
if (kind && !PING_KINDS.has(kind)) return false;
|
||||
if (register && register !== 'safer' && register !== 'bolder') return false;
|
||||
// Legacy shape: a bare challenger id with no kind stays a valid ping.
|
||||
if (!chosenId && !kind) return false;
|
||||
if ((kind === 'challenger' || !kind) && !chosenId) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
await fetch(`${API_BASE}/chosen`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chosenId, key, scope, mode }),
|
||||
body: JSON.stringify({
|
||||
...(chosenId ? { chosenId } : {}),
|
||||
key,
|
||||
scope,
|
||||
mode,
|
||||
...(kind ? { kind } : {}),
|
||||
...(register ? { register } : {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
return true;
|
||||
@@ -260,6 +295,7 @@ export function renderConceptSeed({
|
||||
scope = 'surface',
|
||||
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
|
||||
reroll = 0,
|
||||
register = null,
|
||||
mode = null,
|
||||
grain = null,
|
||||
platform = null,
|
||||
@@ -273,6 +309,15 @@ export function renderConceptSeed({
|
||||
if (!Number.isInteger(reroll) || reroll < 0) {
|
||||
throw new Error('concept-seed: --reroll must be a non-negative integer');
|
||||
}
|
||||
if (register !== null && register !== 'safer' && register !== 'bolder') {
|
||||
throw new Error('concept-seed: --register must be safer or bolder');
|
||||
}
|
||||
if (register !== null && reroll < 1) {
|
||||
throw new Error('concept-seed: --register steers a re-roll round; pass --reroll <n> with it');
|
||||
}
|
||||
if (register !== null && scope !== 'direction') {
|
||||
throw new Error('concept-seed: --register applies to direction rounds only');
|
||||
}
|
||||
if (mode !== null && !SEED_MODES.has(mode)) {
|
||||
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
|
||||
}
|
||||
@@ -326,6 +371,7 @@ export function renderConceptSeed({
|
||||
scope,
|
||||
key,
|
||||
reroll,
|
||||
register,
|
||||
mode,
|
||||
grain,
|
||||
platform,
|
||||
@@ -357,7 +403,11 @@ export function renderConceptSeed({
|
||||
survive the current task plus navigation, quiet and dense content,
|
||||
interaction and state, and a substantially different future surface. In an
|
||||
attended run, present the assigned direction fully committed and offer
|
||||
re-roll; never present a ranked lineup to choose from. Re-roll yourself only
|
||||
re-roll. You may add ONE card for your top-ranked grounded candidate when
|
||||
it is not the assigned direction, kicker MY PICK, with an honest risk line
|
||||
naming its familiarity; one pick card, never a ranked lineup, and the pick
|
||||
never takes the lead position. When the assignment IS your top candidate,
|
||||
there is no pick card. Re-roll yourself only
|
||||
on named factual grounds, when the assignment cannot carry the product's
|
||||
truth or task; taste is never grounds.`
|
||||
: `After ordering the task's grounded structural candidates by resonance,
|
||||
@@ -374,7 +424,16 @@ export function renderConceptSeed({
|
||||
conflicts. Weigh the fused result 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. A fused challenger that wins both axes becomes the build.`
|
||||
list is the point. A fused challenger that wins both axes becomes the build.
|
||||
Close the weighing with a verdict per challenger, decided before any
|
||||
borrowing is considered: wins (beats the assigned direction on both axes),
|
||||
competitive (holds one axis), 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, never
|
||||
the challenger's clothes; one world owns the page. Write each raise as its
|
||||
own named line on the presented direction, and carry every verdict, kept
|
||||
line, and raise into the decision page payload.`
|
||||
: `A challenger wins only when its fused result beats the grounded list on
|
||||
audience identification and product clarity. It may change task topology or
|
||||
interaction, but never the committed visual identity.`;
|
||||
@@ -399,19 +458,55 @@ Ambitious motion, spatial media, or interaction is welcome when it strengthens
|
||||
the product without weakening semantics, performance, or fallback behavior.`;
|
||||
|
||||
if (!data) {
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount})
|
||||
ASSIGNED INDEX: ${buildIndex}
|
||||
// A degraded roll can still serve the safer register, which needs no
|
||||
// catalog at all: the assignment machinery is suppressed entirely, the
|
||||
// same as the non-degraded safer round, because emitting both "the user
|
||||
// picks" and a mandatory numbered build order hands the model two
|
||||
// contradicting instructions and the mandatory one tends to win. The
|
||||
// bolder register is exactly the thing degradation took away, so it
|
||||
// falls back to a plain grounded round, disclosed.
|
||||
const degradedHeader = `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount})`;
|
||||
if (register === 'safer') {
|
||||
return `${degradedHeader}
|
||||
SAFER REGISTER (user-requested): the assigned index is suspended this
|
||||
round; the user picks, and no candidate is mandated. Present the familiar
|
||||
register: your remaining grounded candidates from the conventional end, at
|
||||
most three, as full cards with an honest risk line each, plus the canon
|
||||
executed against two or three named competitors. This is the one sanctioned
|
||||
lineup of your own ranked candidates; it exists only by this explicit
|
||||
request. When the user voices a standing preference for it, record a brand
|
||||
commitment in PRODUCT.md.
|
||||
${authorityInstruction}
|
||||
A user- or brief-pinned decision beats the roll, always.
|
||||
REGISTER (restated for truncated readers): safer, user-requested; the
|
||||
assigned index is suspended this round and the user picks; seed key ${key}.
|
||||
`;
|
||||
}
|
||||
const degradedRegister = register === 'bolder'
|
||||
? `BOLDER REGISTER UNAVAILABLE: bolder deals foreign forms, and this roll ran
|
||||
degraded with no catalog and no roll service, so there is nothing bold to
|
||||
deal. Tell the user, then run this round as a plain grounded re-roll; the
|
||||
assignment below applies.
|
||||
`
|
||||
: '';
|
||||
return `${degradedHeader}
|
||||
${degradedRegister}ASSIGNED INDEX: ${buildIndex}
|
||||
${promotedInstruction}
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
No challengers this run: the roll service was unreachable and no local
|
||||
catalog exists. A sandboxed exec tool with no network access causes exactly
|
||||
this; before accepting degradation, rerun this command once through the
|
||||
harness's network-enabled command tool. A sandboxed shell without network egress is the most common
|
||||
cause: if this harness can rerun the command with network access granted,
|
||||
do that once before proceeding. Otherwise proceed with the grounded
|
||||
candidates alone; the assignment
|
||||
above still applies at full strength. Tell the user plainly that this roll
|
||||
catalog exists. A sandboxed shell without network egress is the most common
|
||||
cause; before accepting degradation, rerun this command once through the
|
||||
harness's network-enabled or escalated command tool. When that rerun needs
|
||||
an approval, state exactly what the approver must know: this script's only
|
||||
network contact is one GET to https://impeccable.style/api/roll whose query
|
||||
carries scope, mode, an eight-hex seed key, and a re-roll counter; no
|
||||
project files, prompts, code, or conversation context are transmitted, and
|
||||
nothing is written. An approval request naming that URL and payload judges
|
||||
the real action; a bare "run with network" invites rejection for contacting
|
||||
an unspecified domain. If the rerun is still refused, proceed with the
|
||||
grounded candidates alone; the assignment above still applies at full
|
||||
strength. Tell the user plainly that this roll
|
||||
ran degraded, with no challengers and no quality-bar boards; do not present
|
||||
the outcome as a full roll. A degraded roll changes the cards, not the
|
||||
channel: when a browser can open, present the direction on the decision page
|
||||
@@ -466,34 +561,76 @@ structure only, never a palette, typeface, or material. Treat them as serious
|
||||
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
|
||||
: '';
|
||||
const rerollBlock = reroll > 0
|
||||
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
|
||||
and challenger alike, is eliminated and may not return reworded. Derive
|
||||
? `RE-ROLL ROUND ${reroll}${register ? ` (${register.toUpperCase()} REGISTER, user-requested)` : ''}: every candidate presented in earlier rounds, grounded
|
||||
and challenger alike, is eliminated and may not return reworded.${register ? '' : ` Derive
|
||||
genuinely new grounded candidates from unexplored angles before judging
|
||||
these fresh challengers.\n`
|
||||
these fresh challengers.`}\n`
|
||||
: '';
|
||||
// A register swaps the round's presentation, never its deal: the assigned
|
||||
// index and challenger fetch stay identical so the chain reproduces, and
|
||||
// only the instructions change.
|
||||
const saferBlock = `SAFER REGISTER: the user asked for the familiar end of the spectrum, so this
|
||||
round's dealt hand is spent unseen, stays excluded from future rounds, and
|
||||
is not printed. The assigned index is suspended this round; the user picks. Present the familiar register: your remaining grounded
|
||||
candidates from the conventional end, at most three, as full cards with an
|
||||
honest risk line each, plus the canon executed against two or three named
|
||||
competitors. This is the one sanctioned lineup of your own ranked
|
||||
candidates; it exists only by this explicit request. When the user voices a
|
||||
standing preference for it, record a brand commitment in PRODUCT.md.`;
|
||||
const bolderBlock = `BOLDER REGISTER: the user asked for foreign forms at full commitment, so no
|
||||
grounded direction is presented this round and the assigned index is
|
||||
suspended. The hand is every dealt challenger below, each fused with the
|
||||
product and presented as a full card; the FIRST dealt challenger leads, an
|
||||
assignment by deal order, so the dice still choose. Verdicts and donations
|
||||
apply between the challengers, weighed against the leader. The pick card
|
||||
sits out; the canon stays, as always.`;
|
||||
const telemetryBlock = data.source === 'api'
|
||||
? `TELEMETRY: if the resolved direction uses one of these challengers, rerun
|
||||
this script once with --chosen <challenger-id> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''}
|
||||
after resolution. The ping is anonymous (chosen id only) and is skipped
|
||||
automatically when DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY is set.\n`
|
||||
? `TELEMETRY: after the user's choice resolves, rerun this script once with
|
||||
--kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''},
|
||||
adding --chosen <challenger-id> when a dealt challenger won and keeping
|
||||
--register <safer|bolder> when the resolved round came from a steered hand.
|
||||
One ping per resolved attended round. The ping is anonymous, the card kind
|
||||
plus the catalog id when one won; your grounded candidates' names never
|
||||
leave the machine, and the ping is skipped automatically when DO_NOT_TRACK
|
||||
or IMPECCABLE_NO_TELEMETRY is set.\n`
|
||||
: '';
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
|
||||
${rerollBlock}ASSIGNED INDEX: ${buildIndex}
|
||||
const assignedBlock = register === null
|
||||
? `ASSIGNED INDEX: ${buildIndex}
|
||||
${promotedInstruction}
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
CHALLENGERS:
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.`
|
||||
: register === 'safer' ? saferBlock : bolderBlock;
|
||||
// A bolder round has no assigned grounded direction, so the generic
|
||||
// weighing instruction (which measures against the assignment) would
|
||||
// contradict the register; the bolder variant weighs against the leader.
|
||||
const bolderChallengerInstruction = `Fuse each challenger before judging it: the challenger supplies the form
|
||||
and its system grammar, the product supplies every fact, and clarity wins
|
||||
conflicts. Weigh every fused challenger against the fused LEADER, the first
|
||||
dealt, on exactly two axes, audience identification and product clarity;
|
||||
verdicts and donations apply between the challengers, and one that beats
|
||||
the leader on both axes presents as the hand's strongest alternate.`;
|
||||
const roundChallengerInstruction = register === 'bolder' ? bolderChallengerInstruction : challengerInstruction;
|
||||
const challengerSection = register === 'safer'
|
||||
? ''
|
||||
: `CHALLENGERS:
|
||||
${data.challengers.map(renderChallenger).join('\n')}
|
||||
${compositionBlock}${challengerInstruction}
|
||||
${compositionBlock}${roundChallengerInstruction}
|
||||
When you can view images, open the QUALITY BAR board and hero for any
|
||||
challenger you weigh seriously and for the world you build. They exist as a
|
||||
craft bar, the finish level and commitment the build is expected to reach,
|
||||
never as a mockup to copy; your surface serves this product, not that render.
|
||||
${authorityInstruction}
|
||||
`;
|
||||
const restated = register === null
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||
: `REGISTER (restated for truncated readers): ${register}, user-requested; the
|
||||
assigned index is suspended this round; seed key ${key}.`;
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
|
||||
${rerollBlock}${assignedBlock}
|
||||
${challengerSection}${authorityInstruction}
|
||||
${richnessInstruction}
|
||||
${telemetryBlock}A user- or brief-pinned decision beats the roll, always.
|
||||
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.
|
||||
${restated}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -502,19 +639,25 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
const fromIdx = args.indexOf('--from');
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
const rerollIdx = args.indexOf('--reroll');
|
||||
const registerIdx = args.indexOf('--register');
|
||||
const modeIdx = args.indexOf('--mode');
|
||||
const grainIdx = args.indexOf('--grain');
|
||||
const platformIdx = args.indexOf('--platform');
|
||||
const candidateCountIdx = args.indexOf('--candidate-count');
|
||||
const chosenIdx = args.indexOf('--chosen');
|
||||
const kindIdx = args.indexOf('--kind');
|
||||
try {
|
||||
if (chosenIdx !== -1) {
|
||||
if (chosenIdx !== -1 || kindIdx !== -1) {
|
||||
// Choice ping: always exits 0, telemetry must never fail a design flow.
|
||||
// --kind alone pings a non-challenger outcome (assigned/pick/canon);
|
||||
// --chosen alone stays the legacy challenger-win ping.
|
||||
const sent = await pingChosen({
|
||||
chosenId: args[chosenIdx + 1],
|
||||
chosenId: chosenIdx !== -1 ? args[chosenIdx + 1] : undefined,
|
||||
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
|
||||
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
|
||||
mode: modeIdx !== -1 ? args[modeIdx + 1] : undefined,
|
||||
kind: kindIdx !== -1 ? args[kindIdx + 1] : undefined,
|
||||
register: registerIdx !== -1 ? args[registerIdx + 1] : undefined,
|
||||
});
|
||||
process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n');
|
||||
} else {
|
||||
@@ -537,6 +680,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
? args[fromIdx + 1]
|
||||
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
|
||||
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
|
||||
register: registerIdx !== -1 ? args[registerIdx + 1] : null,
|
||||
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
|
||||
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
|
||||
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
|
||||
@@ -548,6 +692,13 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
process.exitCode = 1;
|
||||
}
|
||||
// A raced-out fetch may still hold a socket; exit explicitly so the CLI
|
||||
// never lingers on a dead network path after output is written.
|
||||
// never lingers on a dead network path after output is written. Destroy
|
||||
// fetch's global undici dispatcher first: process.exit() with a live
|
||||
// keep-alive socket trips a libuv assertion on Windows and aborts the
|
||||
// process after a successful roll (nodejs/node#56645).
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { loadContext, extractPlatform } from './context.mjs';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
import { readLatestSnapshotAcrossTargets } from './critique-storage.mjs';
|
||||
|
||||
/** Is there code here at all, or just context files / an empty repo? */
|
||||
function hasCode(cwd) {
|
||||
@@ -34,23 +34,13 @@ function hasCode(cwd) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent critique snapshot across all targets. Filenames are
|
||||
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
|
||||
* Parses the small frontmatter for score + P0/P1 counts.
|
||||
* Summarize the most recent critique snapshot across all targets.
|
||||
*/
|
||||
function latestCritique(cwd) {
|
||||
try {
|
||||
const dir = getCritiqueDir(cwd);
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
|
||||
if (!files.length) return null;
|
||||
const newest = files[files.length - 1];
|
||||
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
|
||||
const front = text.split('---')[1] || '';
|
||||
const get = (k) => {
|
||||
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
|
||||
return m ? m[1].trim() : null;
|
||||
};
|
||||
const latest = readLatestSnapshotAcrossTargets({ cwd });
|
||||
if (!latest) return null;
|
||||
const get = (key) => latest.meta[key] ?? null;
|
||||
const num = (v) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
@@ -61,7 +51,7 @@ function latestCritique(cwd) {
|
||||
p0: num(get('p0')),
|
||||
p1: num(get('p1')),
|
||||
timestamp: get('timestamp'),
|
||||
file: path.relative(cwd, path.join(dir, newest)),
|
||||
file: path.relative(cwd, latest.path),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
* shape rather than the markdown block.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -1146,6 +1147,7 @@ async function cli() {
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
@@ -1180,6 +1182,7 @@ async function cli() {
|
||||
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
|
||||
);
|
||||
}
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (!ctx.platform) {
|
||||
// A `## Platform` section that names something we don't recognize (a
|
||||
@@ -1275,9 +1278,10 @@ function appendImageGenDirective(parts) {
|
||||
if (!process.env.OPENAI_API_KEY) return;
|
||||
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
|
||||
parts.push([
|
||||
'IMAGE_GEN_AVAILABLE: An OpenAI key is present, so image generation works even without a harness-native image tool:',
|
||||
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render).`,
|
||||
'Prefer the harness-native image tool when one exists. Visualizing a direction before building it measurably strengthens the result.',
|
||||
'IMAGE_GEN_AVAILABLE: your harness-native image tool is always the first choice for generation; use it whenever one exists.',
|
||||
'This environment also carries an OpenAI key as the fallback for harnesses with no native tool:',
|
||||
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render, and never reach for it when a native tool exists).`,
|
||||
'Visualizing a direction before building it measurably strengthens the result.',
|
||||
].join(' '));
|
||||
}
|
||||
|
||||
@@ -1332,6 +1336,19 @@ function appendDetectorFallback(parts, ctx) {
|
||||
// markdown already in memory, a bounded set of stats, or one of the small JSON
|
||||
// files the boot reads regardless. The deep pass (git drift, token divergence,
|
||||
// cross-workspace sweep) belongs to the doctor command, not to every session.
|
||||
// One boot-time probe replaces every session re-deriving its image toolchain:
|
||||
// harnesses and OSes differ (cwebp, sips on macOS, magick, ffmpeg), and the
|
||||
// agent should read this line instead of running command -v per image.
|
||||
function appendImageToolsDirective(parts) {
|
||||
const probe = process.platform === 'win32' ? 'where' : 'which';
|
||||
const found = ['cwebp', 'sips', 'magick', 'ffmpeg'].filter((tool) => {
|
||||
try { return spawnSync(probe, [tool], { stdio: 'ignore' }).status === 0; } catch { return false; }
|
||||
});
|
||||
parts.push(found.length
|
||||
? `IMAGE_TOOLS: available image converters on this machine: ${found.join(', ')}. Use the first suitable one; never probe again this session.`
|
||||
: 'IMAGE_TOOLS: no image converter found (cwebp, sips, magick, ffmpeg). Ship PNG output unconverted rather than probing per image.');
|
||||
}
|
||||
|
||||
function appendStalenessDirective(parts, ctx, options) {
|
||||
const projectRoot = ctx.projectRoot || process.cwd();
|
||||
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
|
||||
|
||||
@@ -105,28 +105,37 @@ function parseFrontmatter(text) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all snapshot files for `slug`, sorted oldest → newest.
|
||||
* Return snapshot files matching `suffix`, sorted oldest → newest.
|
||||
*/
|
||||
function listSnapshotsForSlug(slug, cwd) {
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
|
||||
|
||||
function listSnapshots(suffix, cwd) {
|
||||
const dir = getCritiqueDir(cwd);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const suffix = `__${slug}.md`;
|
||||
return fs.readdirSync(dir)
|
||||
.filter((f) => f.endsWith(suffix))
|
||||
.filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
|
||||
.sort()
|
||||
.map((f) => path.join(dir, f));
|
||||
}
|
||||
|
||||
function readLatestSnapshotMatching(suffix, cwd) {
|
||||
const filePath = listSnapshots(suffix, cwd).at(-1);
|
||||
if (!filePath) return null;
|
||||
const body = fs.readFileSync(filePath, 'utf-8');
|
||||
return { path: filePath, body, meta: parseFrontmatter(body) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
||||
* to find its fix backlog when the slug matches.
|
||||
*/
|
||||
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
const all = listSnapshotsForSlug(slug, cwd);
|
||||
if (!all.length) return null;
|
||||
const latest = all[all.length - 1];
|
||||
const body = fs.readFileSync(latest, 'utf-8');
|
||||
return { path: latest, body, meta: parseFrontmatter(body) };
|
||||
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
|
||||
}
|
||||
|
||||
/** Return the most recent snapshot across all targets, or null. */
|
||||
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching('.md', cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +143,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
* Critique appends a one-line trend to its output using this.
|
||||
*/
|
||||
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
||||
const all = listSnapshotsForSlug(slug, cwd);
|
||||
const all = listSnapshots(`__${slug}.md`, cwd);
|
||||
const slice = all.slice(-limit);
|
||||
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
|
||||
}
|
||||
|
||||
@@ -683,6 +683,10 @@ if (IS_BROWSER) {
|
||||
|
||||
const reasons = collectVisualContrastReasons(el, style);
|
||||
if (reasons.length === 0) continue;
|
||||
// Image-only mode filters here, inside the cap: gradient/opacity/filter
|
||||
// candidates earlier in DOM order must not consume the budget and
|
||||
// starve the url()-backed texts this mode exists to sample.
|
||||
if (options.imageOnly && !reasons.includes('image background')) continue;
|
||||
|
||||
const textColor = parseRgb(style.color);
|
||||
const fontSize = parseFloat(style.fontSize) || 16;
|
||||
@@ -1175,6 +1179,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
async function analyzeVisualContrast(options = {}) {
|
||||
// imageOnly is enforced inside the collector, before the candidate cap.
|
||||
const candidates = collectVisualContrastCandidates(options);
|
||||
const results = [];
|
||||
const shouldScrollOffscreen = options.scrollOffscreen === true;
|
||||
@@ -1260,9 +1265,16 @@ if (IS_BROWSER) {
|
||||
|
||||
function addBrowserFindings(groupMap, el, findings) {
|
||||
if (!findings || findings.length === 0) return;
|
||||
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
||||
// matching findings for its whole subtree. Applied at this choke point so
|
||||
// every per-element attribution (checks, layout, occlusion, rhythm)
|
||||
// honors it; page-level findings attributed to <body> pass through
|
||||
// untouched, since body has no ignoring ancestor.
|
||||
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
|
||||
if (kept.length === 0) return;
|
||||
const existing = groupMap.get(el);
|
||||
if (existing) existing.push(...findings);
|
||||
else groupMap.set(el, [...findings]);
|
||||
if (existing) existing.push(...kept);
|
||||
else groupMap.set(el, [...kept]);
|
||||
}
|
||||
|
||||
function browserFindingsFromMap(groupMap) {
|
||||
@@ -1620,9 +1632,27 @@ if (IS_BROWSER) {
|
||||
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
|
||||
node.remove();
|
||||
}
|
||||
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
|
||||
if (htmlPatternFindings.length > 0) {
|
||||
const mapped = htmlPatternFindings.map(f => {
|
||||
// Regex findings that name a live selector resolve against the real DOM:
|
||||
// pseudo-element/class segments are stripped (the host element is the
|
||||
// anchor), a selector that matches nothing on this page drops the finding
|
||||
// (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 => {
|
||||
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;
|
||||
}
|
||||
if (matches.length === 0) return false;
|
||||
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
|
||||
});
|
||||
if (scopedHtmlFindings.length > 0) {
|
||||
const mapped = scopedHtmlFindings.map(f => {
|
||||
const item = { type: f.id, detail: f.snippet };
|
||||
if (f.severity) {
|
||||
item.severity = f.severity;
|
||||
@@ -1652,8 +1682,27 @@ if (IS_BROWSER) {
|
||||
};
|
||||
}
|
||||
|
||||
// Visual contrast has three modes. Explicit true runs the full sampled
|
||||
// pass; explicit false disables it entirely (the deterministic-only mode
|
||||
// the test suites use). Unset — the default overlay run — samples ONLY
|
||||
// image-backed text: the one class the analytic walk deliberately skips,
|
||||
// because a url() layer's pixels are unknowable without looking. In-page
|
||||
// sampling draws the source image alone to a canvas (glyph ink never
|
||||
// pollutes it), and a cross-origin image without CORS reports unresolved
|
||||
// instead of guessing.
|
||||
function visualContrastMode(options = {}) {
|
||||
const explicit = typeof options.visualContrast === 'boolean'
|
||||
? options.visualContrast
|
||||
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
|
||||
? window.__IMPECCABLE_CONFIG__.visualContrast
|
||||
: null;
|
||||
if (explicit === true) return 'full';
|
||||
if (explicit === false) return false;
|
||||
return 'image-only';
|
||||
}
|
||||
|
||||
function shouldRunVisualContrast(options = {}) {
|
||||
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
|
||||
return visualContrastMode(options) !== false;
|
||||
}
|
||||
|
||||
function visualContrastOptions(options = {}) {
|
||||
@@ -1830,6 +1879,7 @@ if (IS_BROWSER) {
|
||||
return [];
|
||||
}
|
||||
const resolvedOptions = visualContrastOptions(options);
|
||||
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
|
||||
const analyses = await analyzeVisualContrast(resolvedOptions);
|
||||
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
|
||||
lastVisualContrastAnalyses = analyses;
|
||||
|
||||
@@ -142,10 +142,73 @@ function stripInlineYamlComment(s) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// YAML double-quoted scalars process backslash escapes. Stripping the outer
|
||||
// quotes without unescaping leaves them in place, so a nested font family like
|
||||
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
|
||||
// reaches allowedFonts as '\"ibm plex sans' and never matches the same family
|
||||
// declared in CSS. Scanner instead of a regex: the escape set is small and the
|
||||
// backslash handling stays readable.
|
||||
// The full YAML 1.2 double-quote escape set (spec section 5.7).
|
||||
const YAML_SIMPLE_ESCAPES = {
|
||||
'0': '\0',
|
||||
a: '\x07',
|
||||
b: '\b',
|
||||
t: '\t',
|
||||
n: '\n',
|
||||
v: '\v',
|
||||
f: '\f',
|
||||
r: '\r',
|
||||
e: '\x1b',
|
||||
' ': ' ',
|
||||
'"': '"',
|
||||
'/': '/',
|
||||
'\\': '\\',
|
||||
N: '\u0085',
|
||||
_: '\u00a0',
|
||||
L: '\u2028',
|
||||
P: '\u2029',
|
||||
};
|
||||
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
|
||||
|
||||
function unescapeYamlDoubleQuoted(body) {
|
||||
let out = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch !== '\\' || i === body.length - 1) {
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
const next = body[i + 1];
|
||||
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
|
||||
out += YAML_SIMPLE_ESCAPES[next];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
|
||||
// literal rather than corrupting the rest of the scalar.
|
||||
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
|
||||
if (hexLen) {
|
||||
const hex = body.slice(i + 2, i + 2 + hexLen);
|
||||
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
|
||||
if (codePoint >= 0 && codePoint <= 0x10ffff) {
|
||||
out += String.fromCodePoint(codePoint);
|
||||
i += 1 + hexLen;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseScalar(raw) {
|
||||
const s = raw.trim();
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||
return unescapeYamlDoubleQuoted(s.slice(1, -1));
|
||||
}
|
||||
// Single-quoted YAML escapes only the quote itself, by doubling it.
|
||||
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
|
||||
return s.slice(1, -1).split("''").join("'");
|
||||
}
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.m
|
||||
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
||||
export {
|
||||
walkDir,
|
||||
hasScannableExtension,
|
||||
SCANNABLE_EXTENSIONS,
|
||||
SKIP_DIRS,
|
||||
buildImportGraph,
|
||||
|
||||
@@ -41,6 +41,221 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||
}
|
||||
|
||||
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
||||
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
|
||||
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
|
||||
|
||||
function isInsideOpeningJsxTag(source) {
|
||||
const tagStart = source.lastIndexOf('<');
|
||||
if (tagStart === -1 || !/^<[A-Za-z][\w.:-]*/.test(source.slice(tagStart))) return false;
|
||||
|
||||
let quote = '';
|
||||
for (let cursor = tagStart + 1; cursor < source.length; cursor++) {
|
||||
const char = source[cursor];
|
||||
if (quote) {
|
||||
if (char === '\\') cursor++;
|
||||
else if (char === quote) quote = '';
|
||||
} else if (char === "'" || char === '"') {
|
||||
quote = char;
|
||||
} else if (char === '>') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank JavaScript comments without moving any following source. Regex
|
||||
* findings keep their original line numbers, while prose examples inside
|
||||
* comments cannot masquerade as rendered markup.
|
||||
*/
|
||||
function stripJsComments(content, options = {}) {
|
||||
let state = 'code';
|
||||
let output = '';
|
||||
let lastSignificant = '';
|
||||
let previousSignificant = '';
|
||||
let antePreviousSignificant = '';
|
||||
let currentWord = '';
|
||||
let currentWordPrefix = '';
|
||||
let wordSeparated = false;
|
||||
let regexCharClass = false;
|
||||
let jsxExpressionDepth = 0;
|
||||
let lastClosedBraceKind = '';
|
||||
const braceKinds = [];
|
||||
const templateExpressionDepths = [];
|
||||
|
||||
const braceKind = (startsJsxExpression = false) => (
|
||||
!startsJsxExpression && (
|
||||
!lastSignificant ||
|
||||
lastSignificant === ')' ||
|
||||
lastSignificant === ';' ||
|
||||
lastSignificant === '}' ||
|
||||
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
||||
) ? 'block' : 'expression'
|
||||
);
|
||||
|
||||
const recordSignificant = (char) => {
|
||||
if (/\s/.test(char)) {
|
||||
wordSeparated = true;
|
||||
return;
|
||||
}
|
||||
const isWordChar = /[\w$]/.test(char);
|
||||
if (isWordChar && (wordSeparated || !currentWord)) {
|
||||
currentWord = '';
|
||||
currentWordPrefix = lastSignificant;
|
||||
} else if (!isWordChar) {
|
||||
currentWordPrefix = '';
|
||||
}
|
||||
wordSeparated = false;
|
||||
antePreviousSignificant = previousSignificant;
|
||||
previousSignificant = lastSignificant;
|
||||
lastSignificant = char;
|
||||
currentWord = isWordChar ? currentWord + char : '';
|
||||
};
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const char = content[i];
|
||||
const next = content[i + 1];
|
||||
|
||||
if (state === 'line-comment') {
|
||||
if (char === '\n') {
|
||||
output += char;
|
||||
state = 'code';
|
||||
} else {
|
||||
output += ' ';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state === 'block-comment') {
|
||||
if (char === '*' && next === '/') {
|
||||
output += ' ';
|
||||
i++;
|
||||
state = 'code';
|
||||
} else {
|
||||
output += char === '\n' ? '\n' : ' ';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state === 'regex') {
|
||||
output += char;
|
||||
if (char === '\\' && next) {
|
||||
output += next;
|
||||
i++;
|
||||
} else if (char === '[') {
|
||||
regexCharClass = true;
|
||||
} else if (char === ']') {
|
||||
regexCharClass = false;
|
||||
} else if (char === '/' && !regexCharClass) {
|
||||
state = 'code';
|
||||
recordSignificant('/');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state === 'template' && char === '$' && next === '{') {
|
||||
output += '${';
|
||||
i++;
|
||||
recordSignificant('$');
|
||||
recordSignificant('{');
|
||||
templateExpressionDepths.push(1);
|
||||
braceKinds.push('expression');
|
||||
if (jsxExpressionDepth) jsxExpressionDepth++;
|
||||
state = 'code';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state !== 'code') {
|
||||
output += char;
|
||||
if (char === '\\' && next) {
|
||||
output += next;
|
||||
i++;
|
||||
} else if (
|
||||
(state === 'single-quote' && char === "'") ||
|
||||
(state === 'double-quote' && char === '"') ||
|
||||
(state === 'template' && char === '`')
|
||||
) {
|
||||
state = 'code';
|
||||
recordSignificant(char);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const jsxUrlSeparator = options.jsx && char === '/' && next === '/' &&
|
||||
jsxExpressionDepth === 0 &&
|
||||
(output.endsWith('http:') ||
|
||||
output.endsWith('https:') ||
|
||||
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1)) &&
|
||||
/^[\w.-]+\.[A-Za-z]{2,}(?=[:/?#\s<]|$)/.test(content.slice(i + 2))));
|
||||
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
||||
previousSignificant === lastSignificant &&
|
||||
antePreviousSignificant !== lastSignificant;
|
||||
if (char === '/' && next === '/' && jsxUrlSeparator) {
|
||||
output += '//';
|
||||
i++;
|
||||
recordSignificant('/');
|
||||
recordSignificant('/');
|
||||
} else if (char === '/' && next === '/') {
|
||||
output += ' ';
|
||||
i++;
|
||||
state = 'line-comment';
|
||||
} else if (char === '/' && next === '*') {
|
||||
output += ' ';
|
||||
i++;
|
||||
state = 'block-comment';
|
||||
} else if (templateExpressionDepths.length && char === '{') {
|
||||
output += char;
|
||||
templateExpressionDepths[templateExpressionDepths.length - 1]++;
|
||||
braceKinds.push(braceKind());
|
||||
if (jsxExpressionDepth) jsxExpressionDepth++;
|
||||
recordSignificant(char);
|
||||
} else if (templateExpressionDepths.length && char === '}') {
|
||||
output += char;
|
||||
const depthIndex = templateExpressionDepths.length - 1;
|
||||
templateExpressionDepths[depthIndex]--;
|
||||
lastClosedBraceKind = braceKinds.pop() || '';
|
||||
if (jsxExpressionDepth) jsxExpressionDepth--;
|
||||
recordSignificant(char);
|
||||
if (templateExpressionDepths[depthIndex] === 0) {
|
||||
templateExpressionDepths.pop();
|
||||
state = 'template';
|
||||
}
|
||||
} else if (
|
||||
char === '/' &&
|
||||
(!lastSignificant ||
|
||||
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
||||
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
||||
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
||||
) {
|
||||
output += char;
|
||||
state = 'regex';
|
||||
regexCharClass = false;
|
||||
} else {
|
||||
output += char;
|
||||
const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 &&
|
||||
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1, -1)) ||
|
||||
isInsideOpeningJsxTag(output.slice(0, -1)));
|
||||
if (char === '{') braceKinds.push(braceKind(startsJsxExpression));
|
||||
else if (char === '}') lastClosedBraceKind = braceKinds.pop() || '';
|
||||
if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth++;
|
||||
else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth--;
|
||||
recordSignificant(char);
|
||||
if (char === "'") state = 'single-quote';
|
||||
else if (char === '"') state = 'double-quote';
|
||||
else if (char === '`') state = 'template';
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function stripCssComments(content) {
|
||||
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
|
||||
}
|
||||
|
||||
function firstOverusedGoogleFont(text) {
|
||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||
}
|
||||
@@ -528,18 +743,198 @@ function extractStyleBlocks(content, ext) {
|
||||
|
||||
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
||||
|
||||
function findQuotedStringEnd(content, start, quote) {
|
||||
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||
if (content[cursor] === '\\') cursor++;
|
||||
else if (content[cursor] === quote) return cursor;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findRegexLiteralEnd(content, start) {
|
||||
let inCharacterClass = false;
|
||||
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||
const char = content[cursor];
|
||||
if (char === '\\') {
|
||||
cursor++;
|
||||
} else if (char === '[') {
|
||||
inCharacterClass = true;
|
||||
} else if (char === ']') {
|
||||
inCharacterClass = false;
|
||||
} else if (char === '/' && !inCharacterClass) {
|
||||
while (/[A-Za-z]/.test(content[cursor + 1] || '')) cursor++;
|
||||
return cursor;
|
||||
} else if (char === '\n' || char === '\r') {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findTemplateExpressionEnd(content, start) {
|
||||
let depth = 1;
|
||||
let lastSignificant = '';
|
||||
let previousSignificant = '';
|
||||
let antePreviousSignificant = '';
|
||||
let currentWord = '';
|
||||
let currentWordPrefix = '';
|
||||
let wordSeparated = false;
|
||||
let lastClosedBraceKind = '';
|
||||
const braceKinds = [];
|
||||
|
||||
const braceKind = () => (
|
||||
lastSignificant === ')' ||
|
||||
lastSignificant === ';' ||
|
||||
lastSignificant === '}' ||
|
||||
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
||||
? 'block'
|
||||
: 'expression'
|
||||
);
|
||||
|
||||
const recordSignificant = (char) => {
|
||||
if (/\s/.test(char)) {
|
||||
wordSeparated = true;
|
||||
return;
|
||||
}
|
||||
const isWordChar = /[\w$]/.test(char);
|
||||
if (isWordChar && (wordSeparated || !currentWord)) {
|
||||
currentWord = '';
|
||||
currentWordPrefix = lastSignificant;
|
||||
} else if (!isWordChar) {
|
||||
currentWordPrefix = '';
|
||||
}
|
||||
wordSeparated = false;
|
||||
antePreviousSignificant = previousSignificant;
|
||||
previousSignificant = lastSignificant;
|
||||
lastSignificant = char;
|
||||
currentWord = isWordChar ? currentWord + char : '';
|
||||
};
|
||||
|
||||
for (let cursor = start; cursor < content.length; cursor++) {
|
||||
const char = content[cursor];
|
||||
const next = content[cursor + 1];
|
||||
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
||||
previousSignificant === lastSignificant &&
|
||||
antePreviousSignificant !== lastSignificant;
|
||||
if (char === "'" || char === '"') {
|
||||
cursor = findQuotedStringEnd(content, cursor, char);
|
||||
if (cursor === -1) return -1;
|
||||
recordSignificant(')');
|
||||
} else if (char === '/' && next === '/') {
|
||||
const lineEnd = content.indexOf('\n', cursor + 2);
|
||||
if (lineEnd === -1) return -1;
|
||||
cursor = lineEnd;
|
||||
} else if (char === '/' && next === '*') {
|
||||
const commentEnd = content.indexOf('*/', cursor + 2);
|
||||
if (commentEnd === -1) return -1;
|
||||
cursor = commentEnd + 1;
|
||||
} else if (
|
||||
char === '/' &&
|
||||
(!lastSignificant ||
|
||||
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
||||
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
||||
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
||||
) {
|
||||
cursor = findRegexLiteralEnd(content, cursor);
|
||||
if (cursor === -1) return -1;
|
||||
recordSignificant(')');
|
||||
} else if (char === '`') {
|
||||
cursor = findTemplateLiteralEnd(content, cursor);
|
||||
if (cursor === -1) return -1;
|
||||
recordSignificant(')');
|
||||
} else if (char === '{') {
|
||||
depth++;
|
||||
braceKinds.push(braceKind());
|
||||
recordSignificant(char);
|
||||
} else if (char === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return cursor;
|
||||
lastClosedBraceKind = braceKinds.pop() || '';
|
||||
recordSignificant(char);
|
||||
} else {
|
||||
recordSignificant(char);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findTemplateLiteralEnd(content, start) {
|
||||
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||
const char = content[cursor];
|
||||
if (char === '\\') {
|
||||
cursor++;
|
||||
} else if (char === '`') {
|
||||
return cursor;
|
||||
} else if (char === '$' && content[cursor + 1] === '{') {
|
||||
cursor = findTemplateExpressionEnd(content, cursor + 2);
|
||||
if (cursor === -1) return -1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findCSSinJSTemplates(content) {
|
||||
const templates = [];
|
||||
const tagRe = /\b(?:styled(?:\.\w+|\([^)]+\))|css)/g;
|
||||
let match;
|
||||
while ((match = tagRe.exec(content)) !== null) {
|
||||
let cursor = match.index + match[0].length;
|
||||
while (/\s/.test(content[cursor] || '')) cursor++;
|
||||
|
||||
if (content[cursor] === '<') {
|
||||
let depth = 0;
|
||||
while (cursor < content.length) {
|
||||
const char = content[cursor];
|
||||
if (char === '<') depth++;
|
||||
else if (char === '>' && content[cursor - 1] !== '=') depth--;
|
||||
cursor++;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
if (depth !== 0) continue;
|
||||
while (/\s/.test(content[cursor] || '')) cursor++;
|
||||
}
|
||||
|
||||
if (content[cursor] !== '`') continue;
|
||||
const contentStart = cursor + 1;
|
||||
cursor = findTemplateLiteralEnd(content, cursor);
|
||||
if (cursor === -1) continue;
|
||||
|
||||
templates.push({
|
||||
tagStart: match.index,
|
||||
contentStart,
|
||||
contentEnd: cursor,
|
||||
});
|
||||
tagRe.lastIndex = cursor + 1;
|
||||
}
|
||||
return templates;
|
||||
}
|
||||
|
||||
function extractCSSinJS(content, ext) {
|
||||
ext = ext.toLowerCase();
|
||||
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
||||
const blocks = [];
|
||||
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const before = content.substring(0, m.index);
|
||||
return findCSSinJSTemplates(content).map((template) => {
|
||||
const before = content.substring(0, template.tagStart);
|
||||
const startLine = before.split('\n').length;
|
||||
blocks.push({ content: m[1], startLine });
|
||||
return {
|
||||
content: content.slice(template.contentStart, template.contentEnd),
|
||||
startLine,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function stripCssInJsComments(content, ext) {
|
||||
if (!CSS_IN_JS_EXTENSIONS.has(ext.toLowerCase())) return content;
|
||||
const templates = findCSSinJSTemplates(content);
|
||||
let output = '';
|
||||
let cursor = 0;
|
||||
for (const template of templates) {
|
||||
output += content.slice(cursor, template.contentStart);
|
||||
output += stripCssComments(content.slice(template.contentStart, template.contentEnd));
|
||||
cursor = template.contentEnd;
|
||||
}
|
||||
return blocks;
|
||||
return output + content.slice(cursor);
|
||||
}
|
||||
|
||||
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
|
||||
@@ -627,8 +1022,12 @@ function runTextContentAnalyzers(content, filePath, options = {}) {
|
||||
function detectText(content, filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
const findings = [];
|
||||
const lines = content.split('\n');
|
||||
const ext = extFromFilePath(filePath);
|
||||
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
|
||||
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
|
||||
}) : content;
|
||||
const source = stripCssInJsComments(commentStrippedSource, ext);
|
||||
const lines = source.split('\n');
|
||||
|
||||
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
||||
// Enable block context for CSS files where related properties span multiple lines
|
||||
@@ -661,8 +1060,8 @@ function detectText(content, filePath, options = {}) {
|
||||
phase: 'source',
|
||||
ruleId: 'codex-grid-background',
|
||||
target: filePath,
|
||||
}, () => scanCssTextForGridBackground(content).map(hit => {
|
||||
const line = content.substring(0, hit.index).split('\n').length;
|
||||
}, () => scanCssTextForGridBackground(source).map(hit => {
|
||||
const line = source.substring(0, hit.index).split('\n').length;
|
||||
return finding('codex-grid-background', filePath, hit.snippet, line);
|
||||
})));
|
||||
|
||||
@@ -698,16 +1097,17 @@ function detectText(content, filePath, options = {}) {
|
||||
phase: 'extract',
|
||||
ruleId: 'css-in-js',
|
||||
target: filePath,
|
||||
}, () => extractCSSinJS(content, ext))
|
||||
: extractCSSinJS(content, ext);
|
||||
}, () => extractCSSinJS(source, ext))
|
||||
: extractCSSinJS(source, ext);
|
||||
for (const block of cssJsBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
const blockContent = stripCssComments(block.content);
|
||||
const blockLines = blockContent.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
profile,
|
||||
phase: 'css-in-js',
|
||||
}));
|
||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
|
||||
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 1));
|
||||
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 1));
|
||||
}
|
||||
|
||||
if (options?.designSystem) {
|
||||
|
||||
@@ -226,6 +226,10 @@ const STATIC_INHERITED_PROPS = new Set([
|
||||
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
|
||||
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
|
||||
'webkitHyphens',
|
||||
// visibility inherits in real CSS, and the invisible-at-rest contrast skip
|
||||
// relies on descendants of a hidden container computing as hidden. A child
|
||||
// that declares `visibility: visible` still overrides the inherited value.
|
||||
'visibility',
|
||||
]);
|
||||
|
||||
const STATIC_DEFAULT_STYLE = {
|
||||
@@ -278,6 +282,7 @@ const STATIC_DEFAULT_STYLE = {
|
||||
marginLeft: '0px',
|
||||
position: 'static',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
@@ -334,6 +339,7 @@ const STATIC_PROP_MAP = {
|
||||
'margin-left': 'marginLeft',
|
||||
'position': 'position',
|
||||
'visibility': 'visibility',
|
||||
'opacity': 'opacity',
|
||||
'top': 'top',
|
||||
'right': 'right',
|
||||
'bottom': 'bottom',
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
checkCreamPalette,
|
||||
checkHtmlPatterns,
|
||||
checkKickerAboveHeadingFromDoc,
|
||||
scopedIgnoreActive,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkPageLayout,
|
||||
checkPageQualityFromDoc,
|
||||
@@ -138,10 +139,21 @@ async function detectHtml(filePath, options = {}) {
|
||||
domutils,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return detectText(html, filePath, options);
|
||||
} catch (err) {
|
||||
if (!globalThis.__impeccableStaticHtmlWarned) {
|
||||
globalThis.__impeccableStaticHtmlWarned = true;
|
||||
|
||||
process.stderr.write(
|
||||
'impeccable detect: DEGRADED - HTML parser modules unavailable ' +
|
||||
'(htmlparser2, css-select, css-tree, domutils).\n' +
|
||||
'Falling back to regex matching. Custom properties, selector matching and computed ' +
|
||||
'contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n'
|
||||
);
|
||||
}
|
||||
|
||||
return detectText(html, filePath, options);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const fileDir = path.dirname(resolvedPath);
|
||||
const root = profileStep(profile, {
|
||||
@@ -171,6 +183,9 @@ async function detectHtml(filePath, options = {}) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = window.getComputedStyle(el);
|
||||
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
|
||||
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
||||
// matching findings for its subtree, same as the browser walk.
|
||||
if (scopedIgnoreActive(el, f.id)) continue;
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
}
|
||||
@@ -238,6 +253,17 @@ async function detectHtml(filePath, options = {}) {
|
||||
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html, patternCorpora).filter(item =>
|
||||
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
|
||||
))) {
|
||||
// Selector-backed page findings honor scoped waivers here too, matching
|
||||
// the browser pass: resolve the selector and drop the finding when an
|
||||
// ignoring ancestor covers a match. Unlike the browser, an unmatched
|
||||
// selector keeps the finding — static scans see partial documents.
|
||||
if (f.selector) {
|
||||
let matches = null;
|
||||
try {
|
||||
matches = document.querySelectorAll(String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim());
|
||||
} catch { matches = null; }
|
||||
if (matches && matches.length > 0 && [...matches].every(el => scopedIgnoreActive(el, f.id))) continue;
|
||||
}
|
||||
const item = finding(f.id, filePath, f.snippet);
|
||||
// Position-aware severity promotion: checks may attach a per-finding
|
||||
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
||||
|
||||
@@ -26,11 +26,26 @@ const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
|
||||
const SCANNABLE_EXTENSIONS = new Set([
|
||||
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
||||
'.jsx', '.tsx', '.js', '.ts',
|
||||
'.vue', '.svelte', '.astro',
|
||||
'.vue', '.svelte', '.astro', '.blade.php',
|
||||
]);
|
||||
|
||||
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
||||
|
||||
function hasScannableExtension(filename) {
|
||||
const lower = filename.toLowerCase();
|
||||
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
|
||||
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
|
||||
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
@@ -40,7 +55,7 @@ function walkDir(dir) {
|
||||
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));
|
||||
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -75,26 +90,11 @@ function buildImportGraph(files) {
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
// ES imports: import ... from '...' and import '...'
|
||||
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
|
||||
let m;
|
||||
while ((m = esRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
|
||||
// CSS @import
|
||||
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
|
||||
while ((m = cssRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
|
||||
// SCSS @use / @forward
|
||||
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
|
||||
while ((m = scssRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
|
||||
for (const match of content.matchAll(pattern)) {
|
||||
const resolved = resolveImport(match[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
graph.set(file, imports);
|
||||
@@ -203,6 +203,7 @@ export {
|
||||
SKIP_DIRS,
|
||||
SCANNABLE_EXTENSIONS,
|
||||
HTML_EXTENSIONS,
|
||||
hasScannableExtension,
|
||||
walkDir,
|
||||
resolveImport,
|
||||
buildImportGraph,
|
||||
|
||||
@@ -11,14 +11,21 @@ import {
|
||||
isBrandFontOnOwnDomain,
|
||||
} from '../shared/constants.mjs';
|
||||
import {
|
||||
CSS_NAMED_COLORS,
|
||||
colorToHex,
|
||||
compositeColorOver,
|
||||
contrastRatio,
|
||||
getHue,
|
||||
hasChroma,
|
||||
isNeutralColor,
|
||||
isNoPaintColorValue,
|
||||
oklchToRgb,
|
||||
parseAnyColor,
|
||||
parseColorMix,
|
||||
parseGradientColors,
|
||||
parseRgb,
|
||||
relativeLuminance,
|
||||
splitTopLevelCommas,
|
||||
} from '../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
|
||||
|
||||
@@ -70,6 +77,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
|
||||
//
|
||||
// An element-scoped waiver that travels with the markup: any element carrying
|
||||
// `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
|
||||
// every rule) suppresses matching findings from itself and its entire subtree,
|
||||
// in every engine that walks elements — the browser overlay, the extension,
|
||||
// and the static scan. This is the DOM twin of the line-based
|
||||
// `impeccable-disable` comment directives, which the browser cannot apply (a
|
||||
// live DOM has no line numbers), and the generalization of the one-off
|
||||
// `data-impeccable-allow-kickers` opt-out.
|
||||
//
|
||||
// The intended use is curated exhibits: a page that documents anti-patterns by
|
||||
// example, or renders a deliberate "before" specimen, marks the container once
|
||||
// and every engine skips it while still scanning the page around it.
|
||||
function scopedIgnoreActive(el, ruleId) {
|
||||
const rule = String(ruleId || '').toLowerCase();
|
||||
let cur = el;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
|
||||
if (attr != null) {
|
||||
const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
|
||||
if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
|
||||
}
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Returns true if the given text is composed entirely of emoji characters
|
||||
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
|
||||
// regardless of CSS `color`, so contrast checks against the element's text
|
||||
@@ -637,6 +672,26 @@ function cssTextHasDarkRootBg(content, customProps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Best-effort extraction of the CSS selector whose declaration block contains
|
||||
// the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
|
||||
// anchor, so the browser pass can resolve scoped ignores against the actual
|
||||
// element and drop patterns that render nowhere on the page. Returns null for
|
||||
// @-rule preludes, keyframe steps, nested blocks, and anything that does not
|
||||
// read as a selector; those findings stay page-level.
|
||||
function enclosingCssSelector(cssText, index) {
|
||||
if (!cssText || !Number.isFinite(index)) return null;
|
||||
const open = cssText.lastIndexOf('{', index);
|
||||
if (open === -1) return null;
|
||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
||||
const raw = cssText.slice(prevClose + 1, open).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`
|
||||
// and `to` would read as (never-matching) type selectors and get a valid
|
||||
// finding wrongly dropped by the zero-match rule downstream.
|
||||
if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function scanCssTextForGlow(content) {
|
||||
const customProps = collectCssCustomProps(content);
|
||||
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
|
||||
@@ -948,6 +1003,7 @@ function scanCssTextForPseudoStripe(rawContent) {
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
||||
index: selectorStart,
|
||||
selector,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
@@ -1010,6 +1066,7 @@ function scanCssTextForInsetStripe(content) {
|
||||
findings.push({
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
|
||||
selector,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -1067,7 +1124,7 @@ function collectMarqueeKeyframes(content) {
|
||||
function scanCssTextForMarquee(content, markup = content) {
|
||||
const findings = [];
|
||||
if (/<marquee\b/i.test(markup)) {
|
||||
findings.push({ id: 'marquee', snippet: '<marquee> element' });
|
||||
findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
|
||||
}
|
||||
const marqueeKeyframes = collectMarqueeKeyframes(content);
|
||||
if (marqueeKeyframes.size === 0) return findings;
|
||||
@@ -1082,7 +1139,7 @@ function scanCssTextForMarquee(content, markup = content) {
|
||||
const key = `${selector} ${name}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
|
||||
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
@@ -1453,8 +1510,10 @@ function checkHtmlPatterns(html, corpora) {
|
||||
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
||||
if (purpleHexRe.test(styleText)) {
|
||||
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
|
||||
if (purpleTextRe.test(styleText)) {
|
||||
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
|
||||
purpleTextRe.lastIndex = 0;
|
||||
const purpleMatch = purpleTextRe.exec(styleText);
|
||||
if (purpleMatch) {
|
||||
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1465,7 +1524,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
const start = Math.max(0, gm.index - 200);
|
||||
const context = styleText.substring(start, gm.index + gm[0].length + 200);
|
||||
if (/gradient/i.test(context)) {
|
||||
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
|
||||
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1531,7 +1590,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
const animationToken = bounceMatch[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
|
||||
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
|
||||
}
|
||||
|
||||
// Overshoot cubic-bezier
|
||||
@@ -1540,7 +1599,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
while ((bm = bezierRe.exec(styleText)) !== null) {
|
||||
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
|
||||
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
|
||||
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
|
||||
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1573,18 +1632,21 @@ function checkHtmlPatterns(html, corpora) {
|
||||
|
||||
const glowHits = scanCssTextForGlow(styleText);
|
||||
if (glowHits.length > 0) {
|
||||
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
|
||||
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
|
||||
}
|
||||
|
||||
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
|
||||
const haloHits = scanCssTextForRadialHalo(styleText);
|
||||
if (haloHits.length > 0) {
|
||||
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
|
||||
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
|
||||
}
|
||||
|
||||
// --- Generated-UI tells: repeating-gradient stripes ---
|
||||
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
|
||||
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
|
||||
{
|
||||
const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
|
||||
if (stripesMatch) {
|
||||
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Generated-UI tells: two-axis grid-line background ---
|
||||
@@ -1602,7 +1664,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
// whole gradient layers.
|
||||
const gridHits = scanCssTextForGridBackground(styleText);
|
||||
if (gridHits.length > 0) {
|
||||
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
|
||||
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
|
||||
}
|
||||
|
||||
// --- Generated-copy tells: "X theater" framing copy ---
|
||||
@@ -1622,8 +1684,11 @@ function checkHtmlPatterns(html, corpora) {
|
||||
// hover:rotate / hover:translate utility on an <img>. Each distinct
|
||||
// mechanism is its own finding.
|
||||
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
|
||||
if (imgHoverCss.test(styleText)) {
|
||||
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
|
||||
{
|
||||
const imgHoverMatch = imgHoverCss.exec(styleText);
|
||||
if (imgHoverMatch) {
|
||||
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
|
||||
}
|
||||
}
|
||||
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
|
||||
let im;
|
||||
@@ -1670,7 +1735,46 @@ function readOwnBackgroundColor(el, computedStyle) {
|
||||
return bg;
|
||||
}
|
||||
|
||||
function resolveBackground(el, win, customPropMap) {
|
||||
// One element's background-color as the cascade walk sees it: computed style
|
||||
// first (with the modern-color fallback), then, in static mode only,
|
||||
// custom-prop resolution and the inline-shorthand peek. Shared by
|
||||
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
|
||||
// surfaces.
|
||||
function readCascadeBackgroundColor(current, style, customPropMap) {
|
||||
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
|
||||
// The static engine can return literal "var(--X)" / "oklch(...)" strings.
|
||||
// Resolve through customPropMap so Tailwind v4 color tokens become RGB.
|
||||
if (customPropMap) {
|
||||
bg = parseColorResolved(style.backgroundColor, customPropMap);
|
||||
}
|
||||
if (!bg || bg.a < 0.1) {
|
||||
// Inline-style fallback for colors the static cascade did not surface
|
||||
// on backgroundColor.
|
||||
const rawStyle = current.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
||||
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
|
||||
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
|
||||
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
|
||||
}
|
||||
}
|
||||
}
|
||||
return bg;
|
||||
}
|
||||
|
||||
// Walk up for the surface the element's text is painted on.
|
||||
//
|
||||
// Returns { color, unresolved }:
|
||||
// • color set — the effective surface, overlays composited in.
|
||||
// • unresolved: true — a layer on the way up paints a color this parser
|
||||
// cannot read, so the surface is unknown. Callers
|
||||
// must SKIP their contrast checks. Guessing white
|
||||
// here is what flooded dark themes with false
|
||||
// "on #ffffff" findings: one abstention costs a
|
||||
// single finding, one wrong guess costs a hundred.
|
||||
// • both null/false — no solid color, but a gradient or image is in
|
||||
// play; callers fall back to its color stops.
|
||||
function resolveBackgroundInfo(el, win, customPropMap) {
|
||||
let current = el;
|
||||
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
|
||||
// base. A browser composites these over the base; the old behavior
|
||||
@@ -1698,67 +1802,114 @@ function resolveBackground(el, win, customPropMap) {
|
||||
// body backgrounds.
|
||||
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
|
||||
// (e.g. any color-mix() result), which plain parseRgb misses.
|
||||
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
|
||||
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
|
||||
// through customPropMap so Tailwind v4 color tokens become RGB.
|
||||
if (customPropMap) {
|
||||
bg = parseColorResolved(style.backgroundColor, customPropMap);
|
||||
}
|
||||
if (!bg || bg.a < 0.1) {
|
||||
// Inline-style fallback. jsdom doesn't decompose background
|
||||
// shorthand, so colors set via inline style are otherwise invisible.
|
||||
const rawStyle = current.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
||||
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
|
||||
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
|
||||
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
|
||||
}
|
||||
}
|
||||
let bg = readCascadeBackgroundColor(current, style, customPropMap);
|
||||
|
||||
// `background-color: currentcolor` paints with the element's own text
|
||||
// color — real paint whose value we know. Real browsers resolve the
|
||||
// keyword before getComputedStyle output; jsdom hands it through
|
||||
// verbatim, and without this substitution the layer would read as
|
||||
// unparseable and force a needless abstention.
|
||||
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
|
||||
// The static cascade resolves var() text tokens before checks run, so
|
||||
// style.color is normally already an rgb string here; parseColorResolved
|
||||
// is defense in depth for any future caller that passes a live
|
||||
// customPropMap (it matches the text-color path in checkElementColors
|
||||
// and reduces to parseAnyColor when the map is null or absent).
|
||||
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
|
||||
}
|
||||
|
||||
if (bg && bg.a > 0.1) {
|
||||
if (bg.a >= 0.99) return flatten(bg);
|
||||
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
|
||||
overlays.push(bg);
|
||||
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
|
||||
// This layer names a color we could not parse (a color space we do not
|
||||
// model, an unresolved var(), a syntax newer than the parser). It may
|
||||
// well be opaque, which would make every ancestor below it invisible —
|
||||
// so the surface is unknown and the walk stops here rather than
|
||||
// reporting an ancestor the visitor never sees.
|
||||
return { color: null, unresolved: true };
|
||||
}
|
||||
// No solid bg-color at this level. If THIS level has a gradient/url
|
||||
// with no underlying solid color we can read:
|
||||
// • on body/html: assume white. Body-level gradients are almost
|
||||
// always decorative texture (paper grain, noise) on top of a
|
||||
// solid bg-color the page set via `background: var(--paper)`
|
||||
// shorthand — which jsdom can't decompose into bg-color. The
|
||||
// downstream gradient-stops fallback path produces catastrophic
|
||||
// false positives in this case (gradient noise stops have
|
||||
// accidental browns/blacks that look like card backgrounds).
|
||||
// • on other elements: bail to null and let the caller fall back
|
||||
// to gradient stops (gradient buttons / hero sections are real
|
||||
// bgs worth checking against).
|
||||
// No solid bg-color at this level, but this level paints an image. CSS
|
||||
// stacks background-image layers first-on-top, so which layer leads
|
||||
// decides what the visitor sees:
|
||||
// • gradient on top — the gradient is the surface. Hand the caller a
|
||||
// null color so it falls back to the gradient's own stops (body
|
||||
// grounds, gradient buttons, hero sections).
|
||||
// • url() on top — the surface is an image whose pixels this engine
|
||||
// cannot read, and it may fully cover every layer and ancestor
|
||||
// beneath it. Same contract as an unparseable color: abstain, so
|
||||
// the gradient-stop fallback never measures a gradient the image
|
||||
// hides (the shipped miss: `url(photo), linear-gradient(...)`
|
||||
// reported low-contrast against the invisible gradient's stops).
|
||||
if (hasGradientOrUrl) {
|
||||
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
|
||||
return flatten({ r: 255, g: 255, b: 255, a: 1 });
|
||||
const layers = splitTopLevelCommas(bgImage);
|
||||
const topPaintLayer = layers.find(
|
||||
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
|
||||
);
|
||||
const gradientOnTop = !!topPaintLayer
|
||||
&& /gradient\s*\(/i.test(topPaintLayer)
|
||||
&& !/^\s*url\s*\(/i.test(topPaintLayer);
|
||||
if (!gradientOnTop) return { color: null, unresolved: true };
|
||||
// Gradient on top of a url() layer: the image shows through wherever
|
||||
// the gradient is not fully opaque, so a translucent wash like
|
||||
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
|
||||
// a blend with pixels this engine cannot read. Only a gradient whose
|
||||
// every readable stop is opaque provably covers the image; otherwise
|
||||
// the surface is unknown — abstain rather than hand callers gradient
|
||||
// stops (or a stop average) the visitor never sees unmixed.
|
||||
const urlBeneath = layers.some(
|
||||
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
|
||||
);
|
||||
if (urlBeneath) {
|
||||
const topStops = parseGradientColors(topPaintLayer);
|
||||
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
|
||||
if (!provablyOpaque) return { color: null, unresolved: true };
|
||||
}
|
||||
return null;
|
||||
return { color: null, unresolved: false };
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return flatten({ r: 255, g: 255, b: 255, a: 1 });
|
||||
// Every layer up to the document root was genuinely see-through, so the
|
||||
// browser paints its default canvas. This is the ONLY case that earns the
|
||||
// white assumption.
|
||||
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
|
||||
}
|
||||
|
||||
function resolveBackground(el, win, customPropMap) {
|
||||
return resolveBackgroundInfo(el, win, customPropMap).color;
|
||||
}
|
||||
|
||||
// Walk parents looking for a gradient background and return its color stops.
|
||||
// Used as a fallback when resolveBackground() returns null because the
|
||||
// effective background is a gradient (no single solid color to compare against).
|
||||
// Translucent solid layers found between the element and the gradient (frosted
|
||||
// panels, glass washes) are composited over every stop, the same way
|
||||
// resolveBackground flattens them over a solid base — raw stops alone would
|
||||
// false-flag dark text on a light frosted wash over a dark gradient, and miss
|
||||
// the inverse.
|
||||
function resolveGradientStops(el, win, customPropMap) {
|
||||
let current = el;
|
||||
const overlays = [];
|
||||
while (current && current.nodeType === 1) {
|
||||
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
||||
const bgImage = style.backgroundImage || '';
|
||||
// A url() layer anywhere in the stack — alone, or alongside a gradient in
|
||||
// the same declaration (a translucent wash over a texture photo) — paints
|
||||
// pixels the analytic walk cannot know. Measuring the gradient stops over
|
||||
// the wrong base flagged dark ink sitting on a bright gold-leaf image at
|
||||
// 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
|
||||
// image-backed text.
|
||||
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
|
||||
let stops = null;
|
||||
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
||||
// parseGradientColors (shared) reads modern-space stops too — oklch,
|
||||
// color-mix and friends via balanced-paren token capture — so browser
|
||||
// computed values that keep the authored syntax stay measurable.
|
||||
const parsed = parseGradientColors(bgImage);
|
||||
if (parsed.length > 0) stops = parsed;
|
||||
}
|
||||
if (!stops && !DETECTOR_IS_BROWSER) {
|
||||
// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
|
||||
// Static mode: peek at the raw inline style for gradients the cascade did not surface
|
||||
const rawStyle = current.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
||||
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
||||
@@ -1766,7 +1917,23 @@ function resolveGradientStops(el, win, customPropMap) {
|
||||
if (parsed.length > 0) stops = parsed;
|
||||
}
|
||||
}
|
||||
if (stops) return compositeGradientStops(stops, current, win, customPropMap);
|
||||
if (stops) {
|
||||
const composited = compositeGradientStops(stops, current, win, customPropMap);
|
||||
if (!composited || overlays.length === 0) return composited;
|
||||
return composited.map(stop => {
|
||||
let acc = stop;
|
||||
for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
|
||||
return acc;
|
||||
});
|
||||
}
|
||||
const bg = readCascadeBackgroundColor(current, style, customPropMap);
|
||||
if (bg && bg.a > 0.1) {
|
||||
// An opaque surface above the gradient means the gradient never shows
|
||||
// through here; resolveBackground would have returned it, so reaching
|
||||
// this is defensive — bail rather than measure the wrong layer.
|
||||
if (bg.a >= 0.99) return null;
|
||||
overlays.push(bg);
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
@@ -1986,15 +2153,25 @@ function checkElementColorsDOM(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 10 || rect.height < 10) return [];
|
||||
const style = getComputedStyle(el);
|
||||
// Invisible at rest: hidden scene variants (opacity-0 carousels, swap
|
||||
// decks) are not user-visible, and measuring their inherited colors against
|
||||
// whatever surface happens to sit behind the stack is noise, not audit.
|
||||
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
|
||||
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||
const hasDirectText = directText.trim().length > 0;
|
||||
let effectiveBg = resolveBackground(el);
|
||||
const bgInfo = resolveBackgroundInfo(el);
|
||||
let effectiveBg = bgInfo.color;
|
||||
// An unreadable surface anywhere up the chain: skip the gradient-stop
|
||||
// fallback too, so nothing downstream measures against a ground we never
|
||||
// resolved.
|
||||
let surfaceUnresolved = bgInfo.unresolved;
|
||||
let ownBg = readOwnBackgroundColor(el, style);
|
||||
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
|
||||
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
|
||||
if (pseudoSurface) {
|
||||
ownBg = pseudoSurface;
|
||||
effectiveBg = pseudoSurface;
|
||||
surfaceUnresolved = false;
|
||||
}
|
||||
}
|
||||
return checkColors({
|
||||
@@ -2006,8 +2183,8 @@ function checkElementColorsDOM(el) {
|
||||
// an oklch token near its own oklch background).
|
||||
textColor: parseRgb(style.color) || parseAnyColor(style.color),
|
||||
bgColor: ownBg,
|
||||
effectiveBg,
|
||||
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
|
||||
effectiveBg: surfaceUnresolved ? null : effectiveBg,
|
||||
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
|
||||
fontSize: parseFloat(style.fontSize) || 16,
|
||||
fontWeight: parseInt(style.fontWeight) || 400,
|
||||
hasDirectText,
|
||||
@@ -2157,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
|
||||
});
|
||||
}
|
||||
|
||||
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
|
||||
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
|
||||
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
|
||||
// returns the literal "oklch(...)" string. Without this, the entire
|
||||
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
|
||||
// detector's contrast / color checks.
|
||||
function oklchToRgb(L, C, H) {
|
||||
const hRad = (H * Math.PI) / 180;
|
||||
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
|
||||
}
|
||||
|
||||
function oklabToRgb(L, a, b) {
|
||||
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
||||
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
|
||||
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
|
||||
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
|
||||
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
|
||||
const enc = (x) => {
|
||||
const c = Math.max(0, Math.min(1, x));
|
||||
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
||||
};
|
||||
return {
|
||||
r: Math.round(enc(rLin) * 255),
|
||||
g: Math.round(enc(gLin) * 255),
|
||||
b: Math.round(enc(bLin) * 255),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m0 = l - c / 2;
|
||||
const [r, g, b] =
|
||||
h < 60 ? [c, x, 0] :
|
||||
h < 120 ? [x, c, 0] :
|
||||
h < 180 ? [0, c, x] :
|
||||
h < 240 ? [0, x, c] :
|
||||
h < 300 ? [x, 0, c] : [c, 0, x];
|
||||
return {
|
||||
r: Math.round((r + m0) * 255),
|
||||
g: Math.round((g + m0) * 255),
|
||||
b: Math.round((b + m0) * 255),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function hwbToRgb(h, w, bl) {
|
||||
if (w + bl >= 1) {
|
||||
const g = Math.round((w / (w + bl)) * 255);
|
||||
return { r: g, g, b: g, a: 1 };
|
||||
}
|
||||
const base = hslToRgb(h, 1, 0.5);
|
||||
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
|
||||
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
|
||||
}
|
||||
|
||||
// Common CSS named colors — the handful that actually show up in generated
|
||||
// UIs, not the full 148-name spec list. Includes the achromatic names so a
|
||||
// named gray parses (and correctly reads as no-chroma) instead of being
|
||||
// treated as an unknown color.
|
||||
const CSS_NAMED_COLORS = {
|
||||
black: { r: 0, g: 0, b: 0 },
|
||||
white: { r: 255, g: 255, b: 255 },
|
||||
gray: { r: 128, g: 128, b: 128 },
|
||||
grey: { r: 128, g: 128, b: 128 },
|
||||
silver: { r: 192, g: 192, b: 192 },
|
||||
dimgray: { r: 105, g: 105, b: 105 },
|
||||
darkgray: { r: 169, g: 169, b: 169 },
|
||||
lightgray: { r: 211, g: 211, b: 211 },
|
||||
gainsboro: { r: 220, g: 220, b: 220 },
|
||||
whitesmoke: { r: 245, g: 245, b: 245 },
|
||||
red: { r: 255, g: 0, b: 0 },
|
||||
crimson: { r: 220, g: 20, b: 60 },
|
||||
tomato: { r: 255, g: 99, b: 71 },
|
||||
coral: { r: 255, g: 127, b: 80 },
|
||||
salmon: { r: 250, g: 128, b: 114 },
|
||||
orange: { r: 255, g: 165, b: 0 },
|
||||
gold: { r: 255, g: 215, b: 0 },
|
||||
yellow: { r: 255, g: 255, b: 0 },
|
||||
olive: { r: 128, g: 128, b: 0 },
|
||||
lime: { r: 0, g: 255, b: 0 },
|
||||
green: { r: 0, g: 128, b: 0 },
|
||||
teal: { r: 0, g: 128, b: 128 },
|
||||
turquoise: { r: 64, g: 224, b: 208 },
|
||||
cyan: { r: 0, g: 255, b: 255 },
|
||||
aqua: { r: 0, g: 255, b: 255 },
|
||||
skyblue: { r: 135, g: 206, b: 235 },
|
||||
dodgerblue: { r: 30, g: 144, b: 255 },
|
||||
blue: { r: 0, g: 0, b: 255 },
|
||||
navy: { r: 0, g: 0, b: 128 },
|
||||
indigo: { r: 75, g: 0, b: 130 },
|
||||
rebeccapurple: { r: 102, g: 51, b: 153 },
|
||||
purple: { r: 128, g: 0, b: 128 },
|
||||
violet: { r: 238, g: 130, b: 238 },
|
||||
orchid: { r: 218, g: 112, b: 214 },
|
||||
magenta: { r: 255, g: 0, b: 255 },
|
||||
fuchsia: { r: 255, g: 0, b: 255 },
|
||||
hotpink: { r: 255, g: 105, b: 180 },
|
||||
pink: { r: 255, g: 192, b: 203 },
|
||||
maroon: { r: 128, g: 0, b: 0 },
|
||||
};
|
||||
|
||||
// Split a string on top-level commas (ignoring commas nested in parens).
|
||||
function splitTopLevelCommas(str) {
|
||||
const parts = [];
|
||||
let depth = 0, start = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str[i];
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') depth = Math.max(0, depth - 1);
|
||||
else if (ch === ',' && depth === 0) {
|
||||
parts.push(str.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
const tail = str.slice(start).trim();
|
||||
if (tail) parts.push(tail);
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
|
||||
// the expression can't be resolved (unresolved var(), unknown colors).
|
||||
//
|
||||
// Mixing is done with premultiplied alpha in sRGB regardless of the
|
||||
// declared interpolation space. That is exact for the dominant generated-UI
|
||||
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
|
||||
// result is simply <color> at alpha N% in ANY rectangular space, and a
|
||||
// close-enough approximation for opaque-opaque mixes (the detector only
|
||||
// consumes these values for contrast/chroma thresholds, not for display).
|
||||
function parseColorMix(str) {
|
||||
const m = String(str).trim().match(/^color-mix\(/i);
|
||||
if (!m) return null;
|
||||
// Balanced-paren capture of the arguments.
|
||||
let depth = 0, end = -1;
|
||||
const open = str.indexOf('(');
|
||||
for (let i = open; i < str.length; i++) {
|
||||
if (str[i] === '(') depth++;
|
||||
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
if (end < 0) return null;
|
||||
const args = splitTopLevelCommas(str.slice(open + 1, end));
|
||||
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
|
||||
|
||||
const parseComponent = (component) => {
|
||||
// Percentage may lead or trail the color per spec.
|
||||
let pct = null;
|
||||
let colorStr = component;
|
||||
const trail = component.match(/\s+([\d.]+)%$/);
|
||||
const lead = component.match(/^([\d.]+)%\s+/);
|
||||
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
|
||||
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
|
||||
let color;
|
||||
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
|
||||
else color = parseAnyColor(colorStr);
|
||||
if (!color) return null;
|
||||
return { color, pct };
|
||||
};
|
||||
|
||||
const c1 = parseComponent(args[1]);
|
||||
const c2 = parseComponent(args[2]);
|
||||
if (!c1 || !c2) return null;
|
||||
let p1 = c1.pct, p2 = c2.pct;
|
||||
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
|
||||
else if (p1 == null) p1 = 100 - p2;
|
||||
else if (p2 == null) p2 = 100 - p1;
|
||||
const sum = p1 + p2;
|
||||
if (sum <= 0) return null;
|
||||
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
|
||||
// additionally scaled by sum/100.
|
||||
const w1 = p1 / sum, w2 = p2 / sum;
|
||||
const alphaScale = sum < 100 ? sum / 100 : 1;
|
||||
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
|
||||
const a = (a1 * w1 + a2 * w2) * alphaScale;
|
||||
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
|
||||
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
|
||||
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
|
||||
}
|
||||
|
||||
// Composite a translucent color over an opaque(ish) base (simple
|
||||
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
|
||||
function compositeColorOver(top, base) {
|
||||
const a = top.a ?? 1;
|
||||
return {
|
||||
r: Math.round(top.r * a + base.r * (1 - a)),
|
||||
g: Math.round(top.g * a + base.g * (1 - a)),
|
||||
b: Math.round(top.b * a + base.b * (1 - a)),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
|
||||
// named colors. Returns null on no match. Use this when the input might be
|
||||
// any CSS color form; use plain parseRgb when you only expect computed rgb()
|
||||
// values from real browsers.
|
||||
function parseAnyColor(s) {
|
||||
if (!s || typeof s !== 'string') return null;
|
||||
const str = s.trim();
|
||||
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
|
||||
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
|
||||
let m;
|
||||
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
|
||||
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
|
||||
m = str.match(/^#([0-9a-f]{3,8})$/i);
|
||||
if (m) {
|
||||
const h = m[1];
|
||||
if (h.length === 3 || h.length === 4) {
|
||||
return {
|
||||
r: parseInt(h[0] + h[0], 16),
|
||||
g: parseInt(h[1] + h[1], 16),
|
||||
b: parseInt(h[2] + h[2], 16),
|
||||
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
|
||||
};
|
||||
}
|
||||
if (h.length === 6 || h.length === 8) {
|
||||
return {
|
||||
r: parseInt(h.slice(0, 2), 16),
|
||||
g: parseInt(h.slice(2, 4), 16),
|
||||
b: parseInt(h.slice(4, 6), 16),
|
||||
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
||||
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
||||
// Match L (with optional %), then C and H separated permissively.
|
||||
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const Lnum = parseFloat(m[1]);
|
||||
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
||||
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
||||
if (m[5] !== undefined) {
|
||||
const alpha = parseFloat(m[5]);
|
||||
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
|
||||
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
|
||||
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
|
||||
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
|
||||
const rgb = oklabToRgb(L, a, b);
|
||||
if (m[7] !== undefined) {
|
||||
const alpha = parseFloat(m[7]);
|
||||
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
// HSL/HSLA — comma or space syntax, optional deg on hue.
|
||||
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
|
||||
if (m[4] !== undefined) {
|
||||
const alpha = parseFloat(m[4]);
|
||||
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
// HWB — hue whiteness% blackness%.
|
||||
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
|
||||
if (m[4] !== undefined) {
|
||||
const alpha = parseFloat(m[4]);
|
||||
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
const named = CSS_NAMED_COLORS[str.toLowerCase()];
|
||||
if (named) return { ...named, a: 1 };
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve var() refs in a color string (via customPropMap), then parse.
|
||||
// Returns null on any failure. Used in jsdom-mode paths where
|
||||
@@ -2796,9 +2696,20 @@ function checkElementGlowDOM(el) {
|
||||
if (!boxShadow && !textShadow) return [];
|
||||
// Use parent's background — glow radiates outward, so the surrounding context matters
|
||||
// If resolveBackground returns null (gradient), try to infer from the gradient colors
|
||||
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
|
||||
if (!parentBg) {
|
||||
// Gradient background — sample its colors to determine if it's dark
|
||||
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
|
||||
// Unknown surface (an unreadable layer on the way up): skip only the
|
||||
// gradient hunt below, which would walk PAST that layer and score the
|
||||
// glow against a background the visitor never sees. checkGlow still runs
|
||||
// with a null surface: the zero-offset chromatic halo tell holds on ANY
|
||||
// background, and the static loop already passes the unresolved walk's
|
||||
// null color straight through (detect-html.mjs uses resolveBackground).
|
||||
let parentBg = parentBgInfo.color;
|
||||
if (!parentBg && !parentBgInfo.unresolved) {
|
||||
// Gradient background — sample its colors to determine if it's dark.
|
||||
// Modern-syntax parsing matters here: body-level gradients now reach this
|
||||
// fallback in browser mode, and their stops usually serialize as oklch —
|
||||
// which the shared parseGradientColors reads via its color-function
|
||||
// token capture.
|
||||
let cur = el.parentElement;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const bgImage = getComputedStyle(cur).backgroundImage || '';
|
||||
@@ -2846,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
if (isAIPalette) {
|
||||
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
|
||||
// Also check gradient parents
|
||||
let effectiveBg = parentBg;
|
||||
if (!effectiveBg) {
|
||||
const parentBgInfo = el.parentElement
|
||||
? resolveBackgroundInfo(el.parentElement)
|
||||
: { color: null, unresolved: false };
|
||||
// Unknown surface: leave effectiveBg null (no finding) rather than
|
||||
// hunting gradient ancestors past a layer we could not read.
|
||||
let effectiveBg = parentBgInfo.color;
|
||||
if (!effectiveBg && !parentBgInfo.unresolved) {
|
||||
let cur = el.parentElement;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const gi = getComputedStyle(cur).backgroundImage || '';
|
||||
@@ -3644,10 +3558,19 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
|
||||
}
|
||||
|
||||
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
|
||||
// Invisible at rest, static twin of the browser walk's skip: opacity does
|
||||
// not inherit, so walk ancestors multiplying declared opacity down.
|
||||
if (style.visibility === 'hidden') return [];
|
||||
let effOpacity = 1;
|
||||
for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
|
||||
effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
|
||||
}
|
||||
if (effOpacity <= 0.02) return [];
|
||||
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||
const hasDirectText = directText.trim().length > 0;
|
||||
|
||||
const effectiveBg = resolveBackground(el, window, customPropMap);
|
||||
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
|
||||
const effectiveBg = bgInfo.color;
|
||||
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
|
||||
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
|
||||
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
|
||||
@@ -3693,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
||||
// element itself has no usable own background, that pseudo is the real
|
||||
// surface for contrast purposes.
|
||||
let finalEffectiveBg = effectiveBg;
|
||||
let surfaceUnresolved = bgInfo.unresolved;
|
||||
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
|
||||
const pseudoSurface = window.getPseudoSurface(el);
|
||||
if (pseudoSurface) {
|
||||
ownBg = pseudoSurface;
|
||||
finalEffectiveBg = pseudoSurface;
|
||||
surfaceUnresolved = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3705,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
||||
tag,
|
||||
textColor,
|
||||
bgColor: ownBg,
|
||||
effectiveBg: finalEffectiveBg,
|
||||
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
|
||||
// Unknown surface: hand the checks nothing rather than a guess.
|
||||
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
|
||||
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
|
||||
fontSize: parseFloat(style.fontSize) || 16,
|
||||
fontWeight: parseInt(style.fontWeight) || 400,
|
||||
hasDirectText,
|
||||
@@ -4802,6 +4728,11 @@ function isRenderedForBrowserRule(el) {
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
// scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
|
||||
// returns arbitrary non-zero values for both (a <text> reported 78/48 while
|
||||
// its rendered length sat comfortably inside its box), so the delta is
|
||||
// noise, not overflow. SVG clips to its own viewport anyway.
|
||||
if (el.namespaceURI === 'http://www.w3.org/2000/svg') return [];
|
||||
if (!isRenderedForBrowserRule(el)) return [];
|
||||
// Only the element that actually owns overflowing text — not its ancestors,
|
||||
// which inherit a wider scrollWidth from the spilling descendant.
|
||||
@@ -5186,6 +5117,22 @@ function isPaintedForOcclusion(el) {
|
||||
// path is pure geometry and runs anywhere on the page.
|
||||
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
|
||||
|
||||
// An element whose effective opacity multiplies out to ~0 paints nothing at
|
||||
// rest: it is not user-visible, so visual findings on it (contrast, occlusion)
|
||||
// measure a state nobody sees. Browser-only — the walk needs live computed
|
||||
// styles. Cycling scenes that fade such elements in later are the screenshot
|
||||
// subsystem's territory, not the analytic walk's.
|
||||
function effectiveOpacityDOM(el) {
|
||||
let o = 1;
|
||||
// Walk all the way through body and html: `body { opacity: 0 }` page-fade
|
||||
// wrappers hide every descendant just as thoroughly as a local wrapper.
|
||||
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
|
||||
o *= parseFloat(getComputedStyle(cur).opacity || '1');
|
||||
if (o <= 0.02) return 0;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function checkTextOcclusionDOM() {
|
||||
const findings = [];
|
||||
const seenVictims = new Set();
|
||||
@@ -5213,6 +5160,11 @@ function checkTextOcclusionDOM() {
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// The classic occluder shape this rules out is an opacity-0 interaction
|
||||
// layer — a range scrubber stretched over a before/after comparison — which
|
||||
// elementFromPoint still returns and whose UA background-color otherwise
|
||||
// reads as an opaque box.
|
||||
const effectiveOpacity = effectiveOpacityDOM;
|
||||
|
||||
// Collect renderable text owners in / near the first viewport for the
|
||||
// elementFromPoint probe. SVG <text> counts too.
|
||||
@@ -5225,6 +5177,7 @@ function checkTextOcclusionDOM() {
|
||||
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
|
||||
if (text.length < 2) continue;
|
||||
if (!isPaintedForOcclusion(el)) continue;
|
||||
if (effectiveOpacity(el) <= 0.02) continue;
|
||||
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
|
||||
if (rect.width < 6 || rect.height < 6) continue;
|
||||
// Viewport-bound probe: keep text whose box overlaps the live viewport.
|
||||
@@ -5258,6 +5211,7 @@ function checkTextOcclusionDOM() {
|
||||
if (top === el || el.contains(top) || top.contains(el)) continue;
|
||||
const topCs = getComputedStyle(top);
|
||||
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
|
||||
if (effectiveOpacity(top) <= 0.02) continue;
|
||||
const topTag = top.tagName.toLowerCase();
|
||||
// Text sitting under a raw image/video is contrast territory (deduped
|
||||
// against the pixel low-contrast rule); leave those alone here.
|
||||
@@ -5468,6 +5422,7 @@ export {
|
||||
CSS_NAMED_COLORS,
|
||||
checkBorders,
|
||||
isEmojiOnlyText,
|
||||
scopedIgnoreActive,
|
||||
checkColors,
|
||||
checkHoverContrast,
|
||||
checkElementHoverContrast,
|
||||
@@ -5497,6 +5452,7 @@ export {
|
||||
checkHtmlPatterns,
|
||||
readOwnBackgroundColor,
|
||||
resolveBackground,
|
||||
resolveBackgroundInfo,
|
||||
resolveGradientStops,
|
||||
parseRadiusToPx,
|
||||
resolveBorderRadiusPx,
|
||||
|
||||
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
// The CSS color functions worth pulling out of a longer declaration. The set
|
||||
// is deliberately closed: `linear-gradient(` and `url(` also look like
|
||||
// `name(` and must not be read as colors.
|
||||
const COLOR_FUNCTION_NAMES = new Set([
|
||||
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
|
||||
]);
|
||||
|
||||
// Pull every color-function token out of a value, with balanced-paren capture
|
||||
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
|
||||
// whole. Returns the raw substrings in source order.
|
||||
function extractColorFunctionTokens(value) {
|
||||
const str = String(value || '');
|
||||
const tokens = [];
|
||||
const re = /([a-z][a-z-]*)\(/gi;
|
||||
let m;
|
||||
while ((m = re.exec(str)) !== null) {
|
||||
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
|
||||
let depth = 0, end = -1;
|
||||
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
|
||||
if (str[i] === '(') depth++;
|
||||
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
if (end < 0) break;
|
||||
tokens.push(str.slice(m.index, end + 1));
|
||||
re.lastIndex = end + 1;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function parseGradientColors(bgImage) {
|
||||
if (!bgImage || !bgImage.includes('gradient')) return [];
|
||||
const colors = [];
|
||||
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
|
||||
const c = parseRgb(m[0]);
|
||||
// Stops arrive in whatever syntax the author wrote and the browser kept.
|
||||
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
|
||||
// to read as a gradient with no stops at all.
|
||||
for (const token of extractColorFunctionTokens(bgImage)) {
|
||||
const c = parseAnyColor(token);
|
||||
if (c) colors.push(c);
|
||||
}
|
||||
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
|
||||
@@ -112,13 +144,445 @@ function colorToHex(c) {
|
||||
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// ─── Color-space conversions ────────────────────────────────────────────────
|
||||
//
|
||||
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
|
||||
// and Firefox all keep the authored color space in getComputedStyle output
|
||||
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
|
||||
// so a detector that only reads rgb() is blind on any modern palette. The
|
||||
// expected outputs are pinned in tests/detect-antipatterns.test.js against
|
||||
// what Chrome itself paints for the same strings.
|
||||
|
||||
function clamp01(x) {
|
||||
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
|
||||
}
|
||||
|
||||
// Linear-light sRGB channel to the encoded 0-255 value.
|
||||
function encodeSrgbChannel(x) {
|
||||
const c = clamp01(x);
|
||||
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
|
||||
}
|
||||
|
||||
function decodeSrgbChannel(x) {
|
||||
const c = Number.isFinite(x) ? x : 0;
|
||||
const sign = c < 0 ? -1 : 1;
|
||||
const abs = Math.abs(c);
|
||||
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
|
||||
}
|
||||
|
||||
function linearSrgbToColor(r, g, b, a = 1) {
|
||||
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
|
||||
}
|
||||
|
||||
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
|
||||
function oklabToRgb(L, a, b) {
|
||||
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
||||
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
|
||||
return linearSrgbToColor(
|
||||
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
|
||||
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
|
||||
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
|
||||
);
|
||||
}
|
||||
|
||||
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
|
||||
// the sRGB gamut clamps per channel rather than producing NaN.
|
||||
function oklchToRgb(L, C, H) {
|
||||
const hRad = (H * Math.PI) / 180;
|
||||
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
|
||||
}
|
||||
|
||||
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
|
||||
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
|
||||
function labToRgb(L, a, b) {
|
||||
const kappa = 24389 / 27, epsilon = 216 / 24389;
|
||||
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
|
||||
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
|
||||
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
|
||||
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
|
||||
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
|
||||
return linearSrgbToColor(
|
||||
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
|
||||
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
|
||||
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
|
||||
);
|
||||
}
|
||||
|
||||
function lchToRgb(L, C, H) {
|
||||
const hRad = (H * Math.PI) / 180;
|
||||
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
|
||||
}
|
||||
|
||||
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
|
||||
// `srgb` is what Chrome serializes most color-mix() results into, routinely
|
||||
// with channels outside 0..1. Spaces we do not model return null so callers
|
||||
// abstain instead of measuring against a color we invented.
|
||||
function colorFunctionToRgb(space, c1, c2, c3) {
|
||||
switch (space) {
|
||||
case 'srgb':
|
||||
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
|
||||
case 'srgb-linear':
|
||||
return linearSrgbToColor(c1, c2, c3);
|
||||
case 'display-p3': {
|
||||
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
|
||||
return linearSrgbToColor(
|
||||
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
|
||||
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
|
||||
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m0 = l - c / 2;
|
||||
const [r, g, b] =
|
||||
h < 60 ? [c, x, 0] :
|
||||
h < 120 ? [x, c, 0] :
|
||||
h < 180 ? [0, c, x] :
|
||||
h < 240 ? [0, x, c] :
|
||||
h < 300 ? [x, 0, c] : [c, 0, x];
|
||||
return {
|
||||
r: Math.round((r + m0) * 255),
|
||||
g: Math.round((g + m0) * 255),
|
||||
b: Math.round((b + m0) * 255),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function hwbToRgb(h, w, bl) {
|
||||
if (w + bl >= 1) {
|
||||
const g = Math.round((w / (w + bl)) * 255);
|
||||
return { r: g, g, b: g, a: 1 };
|
||||
}
|
||||
const base = hslToRgb(h, 1, 0.5);
|
||||
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
|
||||
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
|
||||
}
|
||||
|
||||
// Common CSS named colors — the handful that actually show up in generated
|
||||
// UIs, not the full 148-name spec list. Includes the achromatic names so a
|
||||
// named gray parses (and correctly reads as no-chroma) instead of being
|
||||
// treated as an unknown color.
|
||||
const CSS_NAMED_COLORS = {
|
||||
black: { r: 0, g: 0, b: 0 },
|
||||
white: { r: 255, g: 255, b: 255 },
|
||||
gray: { r: 128, g: 128, b: 128 },
|
||||
grey: { r: 128, g: 128, b: 128 },
|
||||
silver: { r: 192, g: 192, b: 192 },
|
||||
dimgray: { r: 105, g: 105, b: 105 },
|
||||
darkgray: { r: 169, g: 169, b: 169 },
|
||||
lightgray: { r: 211, g: 211, b: 211 },
|
||||
gainsboro: { r: 220, g: 220, b: 220 },
|
||||
whitesmoke: { r: 245, g: 245, b: 245 },
|
||||
red: { r: 255, g: 0, b: 0 },
|
||||
crimson: { r: 220, g: 20, b: 60 },
|
||||
tomato: { r: 255, g: 99, b: 71 },
|
||||
coral: { r: 255, g: 127, b: 80 },
|
||||
salmon: { r: 250, g: 128, b: 114 },
|
||||
orange: { r: 255, g: 165, b: 0 },
|
||||
gold: { r: 255, g: 215, b: 0 },
|
||||
yellow: { r: 255, g: 255, b: 0 },
|
||||
olive: { r: 128, g: 128, b: 0 },
|
||||
lime: { r: 0, g: 255, b: 0 },
|
||||
green: { r: 0, g: 128, b: 0 },
|
||||
teal: { r: 0, g: 128, b: 128 },
|
||||
turquoise: { r: 64, g: 224, b: 208 },
|
||||
cyan: { r: 0, g: 255, b: 255 },
|
||||
aqua: { r: 0, g: 255, b: 255 },
|
||||
skyblue: { r: 135, g: 206, b: 235 },
|
||||
dodgerblue: { r: 30, g: 144, b: 255 },
|
||||
blue: { r: 0, g: 0, b: 255 },
|
||||
navy: { r: 0, g: 0, b: 128 },
|
||||
indigo: { r: 75, g: 0, b: 130 },
|
||||
rebeccapurple: { r: 102, g: 51, b: 153 },
|
||||
purple: { r: 128, g: 0, b: 128 },
|
||||
violet: { r: 238, g: 130, b: 238 },
|
||||
orchid: { r: 218, g: 112, b: 214 },
|
||||
magenta: { r: 255, g: 0, b: 255 },
|
||||
fuchsia: { r: 255, g: 0, b: 255 },
|
||||
hotpink: { r: 255, g: 105, b: 180 },
|
||||
pink: { r: 255, g: 192, b: 203 },
|
||||
maroon: { r: 128, g: 0, b: 0 },
|
||||
};
|
||||
|
||||
// Split a string on top-level commas (ignoring commas nested in parens).
|
||||
function splitTopLevelCommas(str) {
|
||||
const parts = [];
|
||||
let depth = 0, start = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str[i];
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') depth = Math.max(0, depth - 1);
|
||||
else if (ch === ',' && depth === 0) {
|
||||
parts.push(str.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
const tail = str.slice(start).trim();
|
||||
if (tail) parts.push(tail);
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
|
||||
// the expression can't be resolved (unresolved var(), unknown colors).
|
||||
//
|
||||
// Mixing is done with premultiplied alpha in sRGB regardless of the
|
||||
// declared interpolation space. That is exact for the dominant generated-UI
|
||||
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
|
||||
// result is simply <color> at alpha N% in ANY rectangular space, and a
|
||||
// close-enough approximation for opaque-opaque mixes (the detector only
|
||||
// consumes these values for contrast/chroma thresholds, not for display).
|
||||
function parseColorMix(str) {
|
||||
const m = String(str).trim().match(/^color-mix\(/i);
|
||||
if (!m) return null;
|
||||
// Balanced-paren capture of the arguments.
|
||||
let depth = 0, end = -1;
|
||||
const open = str.indexOf('(');
|
||||
for (let i = open; i < str.length; i++) {
|
||||
if (str[i] === '(') depth++;
|
||||
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
if (end < 0) return null;
|
||||
const args = splitTopLevelCommas(str.slice(open + 1, end));
|
||||
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
|
||||
|
||||
const parseComponent = (component) => {
|
||||
// Percentage may lead or trail the color per spec.
|
||||
let pct = null;
|
||||
let colorStr = component;
|
||||
const trail = component.match(/\s+([\d.]+)%$/);
|
||||
const lead = component.match(/^([\d.]+)%\s+/);
|
||||
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
|
||||
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
|
||||
let color;
|
||||
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
|
||||
else color = parseAnyColor(colorStr);
|
||||
if (!color) return null;
|
||||
return { color, pct };
|
||||
};
|
||||
|
||||
const c1 = parseComponent(args[1]);
|
||||
const c2 = parseComponent(args[2]);
|
||||
if (!c1 || !c2) return null;
|
||||
let p1 = c1.pct, p2 = c2.pct;
|
||||
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
|
||||
else if (p1 == null) p1 = 100 - p2;
|
||||
else if (p2 == null) p2 = 100 - p1;
|
||||
const sum = p1 + p2;
|
||||
if (sum <= 0) return null;
|
||||
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
|
||||
// additionally scaled by sum/100.
|
||||
const w1 = p1 / sum, w2 = p2 / sum;
|
||||
const alphaScale = sum < 100 ? sum / 100 : 1;
|
||||
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
|
||||
const a = (a1 * w1 + a2 * w2) * alphaScale;
|
||||
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
|
||||
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
|
||||
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
|
||||
}
|
||||
|
||||
// Composite a translucent color over an opaque(ish) base (simple
|
||||
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
|
||||
function compositeColorOver(top, base) {
|
||||
const a = top.a ?? 1;
|
||||
return {
|
||||
r: Math.round(top.r * a + base.r * (1 - a)),
|
||||
g: Math.round(top.g * a + base.g * (1 - a)),
|
||||
b: Math.round(top.b * a + base.b * (1 - a)),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// A color() / lab() / lch() component: a bare number, a percentage against
|
||||
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
|
||||
function parseColorComponent(token, scale = 1) {
|
||||
if (token == null) return null;
|
||||
const t = String(token).trim();
|
||||
if (/^none$/i.test(t)) return 0;
|
||||
const num = parseFloat(t);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return t.endsWith('%') ? (num / 100) * scale : num;
|
||||
}
|
||||
|
||||
function parseAlphaToken(token) {
|
||||
if (token == null) return 1;
|
||||
const t = String(token).trim();
|
||||
if (/^none$/i.test(t)) return 1;
|
||||
const num = parseFloat(t);
|
||||
if (!Number.isFinite(num)) return 1;
|
||||
return t.endsWith('%') ? num / 100 : num;
|
||||
}
|
||||
|
||||
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
|
||||
// color-mix/common named colors. Returns null on no match. Use this when the
|
||||
// input might be any CSS color form; use plain parseRgb when you only expect
|
||||
// computed rgb() values from real browsers.
|
||||
function parseAnyColor(s) {
|
||||
if (!s || typeof s !== 'string') return null;
|
||||
const str = s.trim();
|
||||
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
|
||||
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
|
||||
let m;
|
||||
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
|
||||
if (m) {
|
||||
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
|
||||
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
|
||||
return c;
|
||||
}
|
||||
m = str.match(/^#([0-9a-f]{3,8})$/i);
|
||||
if (m) {
|
||||
const h = m[1];
|
||||
if (h.length === 3 || h.length === 4) {
|
||||
return {
|
||||
r: parseInt(h[0] + h[0], 16),
|
||||
g: parseInt(h[1] + h[1], 16),
|
||||
b: parseInt(h[2] + h[2], 16),
|
||||
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
|
||||
};
|
||||
}
|
||||
if (h.length === 6 || h.length === 8) {
|
||||
return {
|
||||
r: parseInt(h.slice(0, 2), 16),
|
||||
g: parseInt(h.slice(2, 4), 16),
|
||||
b: parseInt(h.slice(4, 6), 16),
|
||||
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
||||
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
||||
// Match L (with optional %), then C and H separated permissively.
|
||||
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const Lnum = parseFloat(m[1]);
|
||||
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
||||
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
||||
if (m[5] !== undefined) {
|
||||
const alpha = parseFloat(m[5]);
|
||||
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
|
||||
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
|
||||
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
|
||||
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
|
||||
const rgb = oklabToRgb(L, a, b);
|
||||
if (m[7] !== undefined) {
|
||||
const alpha = parseFloat(m[7]);
|
||||
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
|
||||
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
|
||||
// spaces L runs 0..100 and 100% means 100.
|
||||
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
|
||||
if (m) {
|
||||
const L = parseColorComponent(m[1], 100);
|
||||
const C = parseColorComponent(m[2], 150);
|
||||
const H = parseFloat(m[3]);
|
||||
if (L == null || C == null || !Number.isFinite(H)) return null;
|
||||
const rgb = lchToRgb(L, C, H);
|
||||
rgb.a = parseAlphaToken(m[4]);
|
||||
return rgb;
|
||||
}
|
||||
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
|
||||
if (m) {
|
||||
const L = parseColorComponent(m[1], 100);
|
||||
const a = parseColorComponent(m[2], 125);
|
||||
const b = parseColorComponent(m[3], 125);
|
||||
if (L == null || a == null || b == null) return null;
|
||||
const rgb = labToRgb(L, a, b);
|
||||
rgb.a = parseAlphaToken(m[4]);
|
||||
return rgb;
|
||||
}
|
||||
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
|
||||
// color-mix() results and for any wide-gamut color an author wrote.
|
||||
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
|
||||
if (m) {
|
||||
const c1 = parseColorComponent(m[2]);
|
||||
const c2 = parseColorComponent(m[3]);
|
||||
const c3 = parseColorComponent(m[4]);
|
||||
if (c1 == null || c2 == null || c3 == null) return null;
|
||||
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
|
||||
if (!rgb) return null;
|
||||
rgb.a = parseAlphaToken(m[5]);
|
||||
return rgb;
|
||||
}
|
||||
// HSL/HSLA — comma or space syntax, optional deg on hue.
|
||||
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
|
||||
if (m[4] !== undefined) {
|
||||
const alpha = parseFloat(m[4]);
|
||||
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
// HWB — hue whiteness% blackness%.
|
||||
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
|
||||
if (m[4] !== undefined) {
|
||||
const alpha = parseFloat(m[4]);
|
||||
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
const named = CSS_NAMED_COLORS[str.toLowerCase()];
|
||||
if (named) return { ...named, a: 1 };
|
||||
return null;
|
||||
}
|
||||
|
||||
// True when a computed background-color string names no paint at all. Used to
|
||||
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
|
||||
// layer has a color we could not read" (stop and abstain).
|
||||
//
|
||||
// `inherit` belongs here even though it is not literally see-through: it means
|
||||
// "paint with the parent's background-color", and walking on to the parent IS
|
||||
// that resolution. Real browsers resolve the keyword before getComputedStyle
|
||||
// output; only jsdom's partial cascade hands it through verbatim, and treating
|
||||
// it as unreadable would make the walk abstain on a surface it can know.
|
||||
// (`currentcolor` is NOT here — it is real paint in the element's own text
|
||||
// color; resolveBackgroundInfo substitutes the computed color for it.)
|
||||
function isNoPaintColorValue(value) {
|
||||
const v = String(value || '').trim().toLowerCase();
|
||||
if (!v) return true;
|
||||
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
|
||||
}
|
||||
|
||||
export {
|
||||
isNeutralColor,
|
||||
parseRgb,
|
||||
relativeLuminance,
|
||||
contrastRatio,
|
||||
parseGradientColors,
|
||||
extractColorFunctionTokens,
|
||||
hasChroma,
|
||||
getHue,
|
||||
colorToHex,
|
||||
oklabToRgb,
|
||||
oklchToRgb,
|
||||
labToRgb,
|
||||
lchToRgb,
|
||||
colorFunctionToRgb,
|
||||
hslToRgb,
|
||||
hwbToRgb,
|
||||
CSS_NAMED_COLORS,
|
||||
splitTopLevelCommas,
|
||||
parseColorMix,
|
||||
parseAnyColor,
|
||||
compositeColorOver,
|
||||
isNoPaintColorValue,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* node hook-admin.mjs off # set enabled: false
|
||||
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
|
||||
* node hook-admin.mjs ignore-rule overused-font --all-values
|
||||
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
|
||||
* node hook-admin.mjs ignore-file <glob> [--shared|--local] # append to ignoreFiles
|
||||
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
||||
* node hook-admin.mjs ignore-value <rule> <value> --local
|
||||
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
|
||||
@@ -166,7 +166,7 @@ function readRawConfigFile(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
|
||||
|
||||
function hookSection(unified) {
|
||||
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
|
||||
@@ -200,6 +200,15 @@ function stripDetectorKeys(raw) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickDetectorKeys(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Write hook runtime config under `hook`, leaving detector filters in
|
||||
// `detector` and preserving sibling keys such as updateCheck.
|
||||
function writeHookConfig(cwd, hookConfig, opts = {}) {
|
||||
@@ -207,10 +216,19 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
|
||||
if (opts.local) ensureHookGitExcludes(cwd);
|
||||
const existingRaw = readRawConfigFile(filePath).raw;
|
||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||
const existingHook = stripDetectorKeys(hookSection(existing));
|
||||
const existingHookSection = hookSection(existing);
|
||||
const existingHook = stripDetectorKeys(existingHookSection);
|
||||
const legacyDetector = pickDetectorKeys(existingHookSection);
|
||||
// Merge over the existing hook object so fields the merge helpers don't manage
|
||||
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
|
||||
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
||||
if (Object.keys(legacyDetector).length > 0) {
|
||||
const existingDetector = detectorSection(existing) || {};
|
||||
next.detector = {
|
||||
...existingDetector,
|
||||
...mergeDetectorConfig(existingDetector, mergeDetectorConfig(legacyDetector)),
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
||||
return filePath;
|
||||
@@ -222,10 +240,14 @@ function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
|
||||
const existingRaw = readRawConfigFile(filePath).raw;
|
||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||
const nextHook = stripDetectorKeys(hookSection(existing));
|
||||
const existingDetector = mergeDetectorConfig(detectorSection(existing));
|
||||
const existingDetectorSection = detectorSection(existing) || {};
|
||||
const existingDetector = mergeDetectorConfig(existingDetectorSection);
|
||||
const next = {
|
||||
...existing,
|
||||
detector: mergeDetectorConfig(detectorConfig, existingDetector),
|
||||
detector: {
|
||||
...existingDetectorSection,
|
||||
...mergeDetectorConfig(detectorConfig, existingDetector),
|
||||
},
|
||||
};
|
||||
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
|
||||
else delete next.hook;
|
||||
@@ -259,12 +281,18 @@ function mergeDetectorConfig(existing, seed = null) {
|
||||
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
|
||||
out.designSystem = { ...seed.designSystem };
|
||||
}
|
||||
if (seed?.advisoryRules === 'include' || seed?.advisoryRules === 'exclude') {
|
||||
out.advisoryRules = seed.advisoryRules;
|
||||
}
|
||||
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
|
||||
out.designSystem = {
|
||||
...(out.designSystem || {}),
|
||||
enabled: base.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
if (base.advisoryRules === 'include' || base.advisoryRules === 'exclude') {
|
||||
out.advisoryRules = base.advisoryRules;
|
||||
}
|
||||
if (Array.isArray(base.ignoreRules)) {
|
||||
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
|
||||
}
|
||||
@@ -558,12 +586,44 @@ function addIgnoreRule(cwd, args) {
|
||||
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
|
||||
}
|
||||
|
||||
function addIgnoreFile(cwd, glob) {
|
||||
function parseIgnoreFileArgs(args) {
|
||||
const positionals = [];
|
||||
let shared = false;
|
||||
let local = false;
|
||||
|
||||
for (const raw of args) {
|
||||
const arg = String(raw || '');
|
||||
if (arg === '--shared') {
|
||||
shared = true;
|
||||
} else if (arg === '--local') {
|
||||
local = true;
|
||||
} else if (arg === '--reason' || arg.startsWith('--reason=')) {
|
||||
throw new Error('--reason is not supported for ignore-file because detector.ignoreFiles stores globs only; use ignore-value when a documented rule-specific exception fits');
|
||||
} else if (arg.startsWith('--')) {
|
||||
throw new Error(`Unknown ignore-file flag: ${arg}`);
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
if (shared && local) throw new Error('Pass only one scope flag: --shared or --local');
|
||||
if (positionals.length > 1) throw new Error('Pass exactly one glob to ignore-file');
|
||||
|
||||
return {
|
||||
glob: positionals[0],
|
||||
local,
|
||||
};
|
||||
}
|
||||
|
||||
function addIgnoreFile(cwd, args) {
|
||||
const parsed = parseIgnoreFileArgs(args);
|
||||
const glob = parsed.glob;
|
||||
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local: parsed.local }));
|
||||
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
||||
writeDetectorConfig(cwd, config);
|
||||
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
|
||||
const target = writeDetectorConfig(cwd, config, { local: parsed.local });
|
||||
const scope = parsed.local ? 'local detector.ignoreFiles' : 'shared detector.ignoreFiles';
|
||||
return `Added "${glob}" to ${scope} (${path.relative(cwd, target) || target}). Current: ${config.ignoreFiles.join(', ')}`;
|
||||
}
|
||||
|
||||
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
|
||||
@@ -727,7 +787,7 @@ function main() {
|
||||
case 'on': out = setEnabled(cwd, true); break;
|
||||
case 'off': out = setEnabled(cwd, false); break;
|
||||
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
|
||||
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
|
||||
case 'ignore-file': out = addIgnoreFile(cwd, rest); break;
|
||||
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
|
||||
case 'reset': out = reset(cwd); break;
|
||||
}
|
||||
|
||||
@@ -16,13 +16,18 @@ import path from 'node:path';
|
||||
|
||||
import {
|
||||
ALLOWED_EXTS,
|
||||
DEFAULT_CONFIG,
|
||||
EDIT_COUNT_THRESHOLD,
|
||||
GENERATED_PATH,
|
||||
SENSITIVE_PATH,
|
||||
appendDesignSystemNote,
|
||||
appendDesignSystemNoteOnce,
|
||||
commitFooterShown,
|
||||
designNoteReserve,
|
||||
designSystemOptions,
|
||||
footerModeForSession,
|
||||
filterFindings,
|
||||
isNativePlatform,
|
||||
isScanTargetInsideProject,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
@@ -161,7 +166,7 @@ function replaceOnce(original, oldString, newString) {
|
||||
}
|
||||
|
||||
function readExistingProjectFile(filePath, cwd) {
|
||||
if (!isInsideProject(filePath, cwd)) return null;
|
||||
if (!isScanTargetInsideProject(filePath, cwd)) return null;
|
||||
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
@@ -232,7 +237,7 @@ function shellCopiedFileContent(command, cwd) {
|
||||
const source = shellCopyPaths(command)?.source;
|
||||
if (!source) return '';
|
||||
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
|
||||
if (!isInsideProject(sourcePath, cwd)) return '';
|
||||
if (!isScanTargetInsideProject(sourcePath, cwd)) return '';
|
||||
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
|
||||
try {
|
||||
const stat = fs.statSync(sourcePath);
|
||||
@@ -328,15 +333,6 @@ function relativePath(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
function isInsideProject(filePath, cwd) {
|
||||
try {
|
||||
const rel = path.relative(cwd, filePath);
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
@@ -353,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
// Cursor caps deny messages around 4000 chars. The cap feeds through the
|
||||
// renderer's clamp, which preserves the policy footer, rather than tail-
|
||||
// slicing the rendered text, which cut the footer off any message the
|
||||
// default 8000-char budget let past 4000.
|
||||
const CURSOR_DENY_LIMIT = 4000;
|
||||
const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. ';
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) {
|
||||
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
||||
// Charge the prefix via reserveChars, not by subtracting from maxChars:
|
||||
// renderTemplate's 500-char floor re-raises any maxChars pushed below it,
|
||||
// un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508).
|
||||
// reserveChars comes off after the floor, so the prefix is charged at every
|
||||
// config tier and the final prefixed message plus a pending staleness note
|
||||
// fits the binding limit. Default-config output is byte-identical.
|
||||
const budget = Math.min(
|
||||
limits.maxChars || DEFAULT_CONFIG.limits.maxChars,
|
||||
CURSOR_DENY_LIMIT,
|
||||
);
|
||||
const rendered = renderTemplate(findings, filePath,
|
||||
{ ...config, limits: { ...limits, maxChars: budget } },
|
||||
{ cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length });
|
||||
return rendered.replace(
|
||||
'[impeccable@1] Design hook findings requiring review',
|
||||
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
|
||||
`[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`,
|
||||
);
|
||||
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
|
||||
}
|
||||
|
||||
function findingSignature(findings) {
|
||||
@@ -414,7 +429,7 @@ async function main() {
|
||||
};
|
||||
|
||||
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
|
||||
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
||||
if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
@@ -476,9 +491,16 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions);
|
||||
const sessionId = event.session_id || event.conversation_id || 'unknown';
|
||||
const cache = readCache(cwd);
|
||||
// Repeated denials for the same session repeat the findings, not the
|
||||
// policy: the full footer emits once per session, the short form after.
|
||||
const footerMode = footerModeForSession(cache, sessionId);
|
||||
const message = appendDesignSystemNoteOnce(
|
||||
cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)),
|
||||
scanOptions, cache, sessionId, config,
|
||||
);
|
||||
commitFooterShown(cache, sessionId, message);
|
||||
const denial = bumpCursorDenial(cache, sessionId, filePath, filtered);
|
||||
persistCache(cwd, cache);
|
||||
if (denial.count > EDIT_COUNT_THRESHOLD) {
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
||||
* renderTemplate(findings, filePath, config, opts)
|
||||
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
|
||||
* appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config)
|
||||
* designNoteReserve(scanOptions, cache, sessionId)
|
||||
* footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text)
|
||||
* shouldEmitAckForFile(filePath, config?)
|
||||
* writeAuditLog(env, entry)
|
||||
* loadDetector() -> Promise<{ detectText, detectHtml }>
|
||||
@@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return '';
|
||||
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
||||
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
||||
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
|
||||
// reserveChars holds back room for a note the caller appends after render
|
||||
// (the DESIGN.md staleness note), so the final payload stays inside the
|
||||
// configured budget. It comes off after the 500-char floor, so at floor
|
||||
// configs the note keeps guaranteed delivery room; the clamp budget can
|
||||
// therefore sit below 500, which clampLastLine's footer-preserving
|
||||
// fallback handles (Bugbot on PR #508).
|
||||
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0);
|
||||
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const display = relativize(filePath, cwd);
|
||||
@@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
||||
const remaining = total - shown.length;
|
||||
|
||||
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
|
||||
const lines = shown.map((f) => formatFindingLine(f));
|
||||
const seenRules = new Set();
|
||||
const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules));
|
||||
const more = remaining > 0
|
||||
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
|
||||
: null;
|
||||
const footer = directiveFooter(display);
|
||||
const footer = directiveFooter({ mode: opts.footer });
|
||||
|
||||
const blocks = [header, ...lines];
|
||||
if (more) blocks.push(more);
|
||||
@@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
|
||||
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
||||
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
||||
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
|
||||
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0);
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
|
||||
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
|
||||
const lines = [];
|
||||
let shownCount = 0;
|
||||
// One seen-set across all groups: a rule already described under one file
|
||||
// is not re-described under the next.
|
||||
const seenRules = new Set();
|
||||
|
||||
for (const group of realGroups) {
|
||||
const display = relativize(group.filePath, cwd);
|
||||
@@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
const remainingCap = Math.max(0, cap - shownCount);
|
||||
const shown = group.findings.slice(0, remainingCap);
|
||||
for (const finding of shown) {
|
||||
lines.push(formatFindingLine(finding));
|
||||
lines.push(formatDedupedFindingLine(finding, seenRules));
|
||||
}
|
||||
shownCount += shown.length;
|
||||
const hidden = group.findings.length - shown.length;
|
||||
@@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const footer = directiveFooter('the affected files', { grouped: true });
|
||||
const footer = directiveFooter({ mode: opts.footer });
|
||||
let text = [header, ...lines, '', footer].join('\n');
|
||||
if (text.length > maxChars) {
|
||||
text = clampGroupedToBudget(header, lines, footer, maxChars);
|
||||
@@ -1037,82 +1050,149 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// The clamp contract, shared by both budget functions: the footer is policy,
|
||||
// not detail, so it survives every clamp. Try the requested footer first;
|
||||
// when it cannot fit even after dropping finding lines, retry with the short
|
||||
// policy rather than sacrifice findings that fit beside it. A result that
|
||||
// dropped every finding line (a grouped render can fit a bare file header)
|
||||
// does not count as a fit: findings are why the emission exists.
|
||||
const isFindingLine = (line) => line.startsWith('- ');
|
||||
|
||||
function footerFallbacks(footer) {
|
||||
const short = directiveFooter({ mode: 'short' });
|
||||
return footer === short ? [footer] : [footer, short];
|
||||
}
|
||||
|
||||
function clampGroupedToBudget(header, lines, footer, maxChars) {
|
||||
const assemble = (linesArr, omitted) => [
|
||||
const assemble = (linesArr, omitted, footerText) => [
|
||||
header,
|
||||
...linesArr,
|
||||
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
|
||||
'',
|
||||
footer,
|
||||
footerText,
|
||||
].join('\n');
|
||||
|
||||
let working = lines.slice();
|
||||
let omitted = false;
|
||||
let assembled = assemble(working, omitted);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
omitted = true;
|
||||
assembled = assemble(working, omitted);
|
||||
for (const footerText of footerFallbacks(footer)) {
|
||||
let working = lines.slice();
|
||||
let omitted = false;
|
||||
let assembled = assemble(working, omitted, footerText);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
omitted = true;
|
||||
assembled = assemble(working, omitted, footerText);
|
||||
}
|
||||
if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled;
|
||||
}
|
||||
if (assembled.length > maxChars) {
|
||||
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
||||
}
|
||||
return assembled;
|
||||
return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText),
|
||||
lines.find(isFindingLine) || lines[0], maxChars);
|
||||
}
|
||||
|
||||
function clampToBudget(header, lines, more, footer, maxChars) {
|
||||
const assemble = (linesArr, moreText) => {
|
||||
const assemble = (linesArr, moreText, footerText) => {
|
||||
const blocks = [header, ...linesArr];
|
||||
if (moreText) blocks.push(moreText);
|
||||
blocks.push('');
|
||||
blocks.push(footer);
|
||||
blocks.push(footerText);
|
||||
return blocks.join('\n');
|
||||
};
|
||||
|
||||
let working = lines.slice();
|
||||
let moreText = more;
|
||||
let assembled = assemble(working, moreText);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
|
||||
assembled = assemble(working, moreText);
|
||||
let lastMore = more;
|
||||
for (const footerText of footerFallbacks(footer)) {
|
||||
let working = lines.slice();
|
||||
let moreText = more;
|
||||
let assembled = assemble(working, moreText, footerText);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
|
||||
assembled = assemble(working, moreText, footerText);
|
||||
}
|
||||
lastMore = moreText;
|
||||
if (assembled.length <= maxChars) return assembled;
|
||||
}
|
||||
if (assembled.length > maxChars) {
|
||||
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
||||
}
|
||||
return assembled;
|
||||
return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText),
|
||||
lines.find(isFindingLine) || lines[0], maxChars);
|
||||
}
|
||||
|
||||
function formatFindingLine(f) {
|
||||
// Last resort with one finding line left: the short policy gets the budget
|
||||
// first, the line is clipped to what remains. The pre-fix tail-slice cut
|
||||
// whatever happened to be last, which was always the footer.
|
||||
function clampLastLine(build, line, maxChars) {
|
||||
const footerText = directiveFooter({ mode: 'short' });
|
||||
const bare = build([], footerText);
|
||||
// +1 for the newline the line itself brings when it joins the blocks.
|
||||
const room = maxChars - bare.length - 1;
|
||||
if (room >= 24) {
|
||||
const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line;
|
||||
return build([clipped], footerText);
|
||||
}
|
||||
// No room for even a clipped finding line: the note reservation can pull
|
||||
// the budget below the 500-char floor, and a deep file path can push the
|
||||
// header past what remains beside the short policy (Bugbot on PR #508).
|
||||
// Drop the line, and if the bare header + policy still overflow, clip the
|
||||
// head. Never tail-slice: the footer sits at the end, so a tail slice is
|
||||
// exactly the footer cut this renderer exists to prevent.
|
||||
if (bare.length <= maxChars) return bare;
|
||||
const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4));
|
||||
return `${head}…\n\n${footerText}`;
|
||||
}
|
||||
|
||||
// `compact` drops the registry description: within one emission the first
|
||||
// occurrence of a rule carries the full description and repeats keep only the
|
||||
// rule id, name, and their own ignore hint (values differ per line, so the
|
||||
// hint must survive the dedupe).
|
||||
function formatFindingLine(f, opts = {}) {
|
||||
const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-';
|
||||
const desc = (f.description || '').trim();
|
||||
const desc = opts.compact ? '' : (f.description || '').trim();
|
||||
const name = (f.name || '').trim();
|
||||
// Description from the registry already ends in punctuation; join with a
|
||||
// single space. `name` may have a trailing period already, keep it clean.
|
||||
const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
|
||||
const ignoreCommand = formatFindingIgnoreCommand(f);
|
||||
const ignoreSegment = ignoreCommand
|
||||
? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`
|
||||
: '';
|
||||
const ignoreHint = formatFindingIgnoreHint(f);
|
||||
const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : '';
|
||||
return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function formatFindingIgnoreCommand(finding) {
|
||||
// Dedupe applied in shown-line order, so the first rendered occurrence of a
|
||||
// rule always carries the description. The budget clamps pop lines from the
|
||||
// end, which can never orphan a compact repeat before its described first
|
||||
// occurrence.
|
||||
function formatDedupedFindingLine(finding, seenRules) {
|
||||
const rule = normalizeIgnoreRule(finding?.antipattern);
|
||||
const compact = rule ? seenRules.has(rule) : false;
|
||||
if (rule) seenRules.add(rule);
|
||||
return formatFindingLine(finding, { compact });
|
||||
}
|
||||
|
||||
// The rule/value pair the footer's `hook-admin.mjs ignore-value` command
|
||||
// takes. Deliberately just the args: the executable prefix, the --reason
|
||||
// contract, and the disclosure rule live in the directive footer, stated once
|
||||
// instead of per line.
|
||||
function formatFindingIgnoreHint(finding) {
|
||||
if (!finding || typeof finding !== 'object') return '';
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
if (!rule) return '';
|
||||
const normalizedValue = extractFindingIgnoreValue(finding);
|
||||
if (!normalizedValue) return '';
|
||||
const value = extractFindingIgnoreValueRaw(finding);
|
||||
const valueArg = quoteCommandArg(value);
|
||||
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
|
||||
return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
||||
const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding));
|
||||
return `ignore-value ${rule} ${valueArg}`;
|
||||
}
|
||||
|
||||
function quoteCommandArg(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
|
||||
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
// The suggestion is meant to be run on this same machine, so quote for its
|
||||
// shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside
|
||||
// double quotes, and these values come from scanned file content (a
|
||||
// font-family name) or a file path, so untrusted input must be
|
||||
// single-quoted (issue #476). Windows cmd.exe performs no such command
|
||||
// substitution, but it treats a single quote as a literal character rather
|
||||
// than a grouping delimiter, so a value or path containing spaces has to
|
||||
// stay double-quoted there (Greptile #533). Keep the pre-existing
|
||||
// double-quote escaping on Windows so that path's behavior is unchanged.
|
||||
if (process.platform === 'win32') {
|
||||
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
return `'${text.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function relativize(filePath, cwd) {
|
||||
@@ -1335,6 +1415,51 @@ function isInsideProject(filePath, projectCwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a path to its canonical (symlink-free) form. When the path does
|
||||
// not exist yet — the before-edit hook gates proposed Writes — canonicalize
|
||||
// the nearest existing ancestor and re-append the remainder, so a new file
|
||||
// under a symlinked root still compares equal to its canonical project.
|
||||
// Memoized: the hook runs as a fresh process per tool event, so the cache
|
||||
// amounts to once-per-event work — the scan loops re-check the same project
|
||||
// root for every target file. The cap only matters to long-lived importers
|
||||
// like the test runner.
|
||||
const canonicalPathCache = new Map();
|
||||
const CANONICAL_PATH_CACHE_MAX = 1024;
|
||||
|
||||
function canonicalPath(p) {
|
||||
const resolved = path.resolve(p);
|
||||
if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved);
|
||||
let canonical = resolved;
|
||||
let dir = resolved;
|
||||
const tail = [];
|
||||
while (true) {
|
||||
try {
|
||||
canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir);
|
||||
break;
|
||||
} catch { /* keep climbing */ }
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
tail.unshift(path.basename(dir));
|
||||
dir = parent;
|
||||
}
|
||||
if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear();
|
||||
canonicalPathCache.set(resolved, canonical);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
// Containment gate shared by the before-edit hook and both scan passes. A
|
||||
// session routinely touches files that belong to no project or to a
|
||||
// different one — harness scratchpad dirs under the system temp root,
|
||||
// sibling checkouts, one-off throwaway HTML — and findings against those are
|
||||
// judged with THIS project's config and DESIGN.md palette, which is never
|
||||
// right. Skip them (audit reason: outside-project). Paths are canonicalized
|
||||
// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the
|
||||
// comparison.
|
||||
export function isScanTargetInsideProject(filePath, projectCwd) {
|
||||
if (!filePath || !projectCwd) return false;
|
||||
return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd));
|
||||
}
|
||||
|
||||
export function parseStaticStyleImports(content, fromFile, projectCwd) {
|
||||
if (!content || typeof content !== 'string') return [];
|
||||
const dir = path.dirname(fromFile);
|
||||
@@ -1549,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) {
|
||||
}
|
||||
}
|
||||
|
||||
const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
|
||||
|
||||
export function appendDesignSystemNote(text, scanOptions) {
|
||||
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
||||
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
|
||||
return `${text}\n\n${DESIGN_STALE_NOTE}`;
|
||||
}
|
||||
|
||||
// Session-scoped once-only gate for repeat-prone message parts. Returns true
|
||||
// the first time a flag is consumed in a session and false after, mirroring
|
||||
// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not
|
||||
// change between edits, so re-stating them on every emission spends context
|
||||
// to say nothing new. Callers must persist the cache for the flag to stick.
|
||||
function consumeSessionNoticeFlag(cache, sessionId, flag) {
|
||||
const session = ensureSession(cache, sessionId);
|
||||
if (session[flag]) return false;
|
||||
session[flag] = true;
|
||||
session.updatedAt = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Once-per-session variant of appendDesignSystemNote for the emission paths
|
||||
// that have cache access. The staleness note names standing project state,
|
||||
// not new information, so one mention per session is enough. The note is
|
||||
// appended after the renderer has clamped to the configured budget: render
|
||||
// paths reserve room for it via designNoteReserve, and the size check here
|
||||
// is the safety net for the ack paths, deferring (without consuming the
|
||||
// flag) to a later emission rather than busting maxChars.
|
||||
export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) {
|
||||
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
||||
const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars);
|
||||
if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text;
|
||||
if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text;
|
||||
return appendDesignSystemNote(text, scanOptions);
|
||||
}
|
||||
|
||||
// Render-time reservation for the note above: how many characters the
|
||||
// renderer must hold back so a pending staleness note still fits inside the
|
||||
// configured budget. Zero once the session has seen the note. Without the
|
||||
// reservation, a session whose every emission fills the budget would defer
|
||||
// the note forever.
|
||||
export function designNoteReserve(scanOptions, cache, sessionId) {
|
||||
if (!scanOptions?.designSystem?.mdNewerThanJson) return 0;
|
||||
if (ensureSession(cache, sessionId).designNoteShown) return 0;
|
||||
return DESIGN_STALE_NOTE.length + 2;
|
||||
}
|
||||
|
||||
// Full directive footer once per session, the short reminder after. Fresh
|
||||
// emissions and Cursor denials share the session flag (`footerShown`), so a
|
||||
// session pays the full policy exactly once however it first fires. The mode
|
||||
// is a peek: the clamp can downgrade a requested full footer under a tight
|
||||
// budget, so the flag commits only when the complete full policy actually
|
||||
// reached the output. Matching the whole footer text (not a sentinel) keeps
|
||||
// the flag honest against any truncation that spares the opening words.
|
||||
export function footerModeForSession(cache, sessionId) {
|
||||
return ensureSession(cache, sessionId).footerShown ? 'short' : 'full';
|
||||
}
|
||||
|
||||
export function commitFooterShown(cache, sessionId, text) {
|
||||
if (!text || !text.includes(directiveFooter())) return;
|
||||
const session = ensureSession(cache, sessionId);
|
||||
if (session.footerShown) return;
|
||||
session.footerShown = true;
|
||||
session.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`;
|
||||
|
||||
// The directive footer is the part of the hook output that steers model
|
||||
// behavior. Three intentional moves:
|
||||
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
|
||||
// revising..." which the model treats as a soft suggestion it can
|
||||
// override when the user asked for any kind of throwaway / demo UI.
|
||||
// 2. **Explicit judgment clause.** Without it, the model will try to
|
||||
// "fix" intentional motion, bad fixtures, anti-pattern examples in
|
||||
// docs, or test cases. Naming the judgment inline beats hoping the
|
||||
// model infers it from context.
|
||||
// 3. **Acknowledgement instruction.** Hook output is injected as
|
||||
// developer-role context, not a chat turn, so the user never sees the
|
||||
// raw envelope. Asking the model to surface the resolution in its
|
||||
// reply is the cheapest way to make the feedback loop visible.
|
||||
function directiveFooter(display, opts = {}) {
|
||||
// Offer the rule-scoped-to-file form first. `ignore-file` silences every rule
|
||||
// for the path forever, which is far more than one noisy rule on a real UI
|
||||
// surface justifies, and it was previously the only option named here.
|
||||
const target = opts.grouped ? '<path>' : quoteCommandArg(display);
|
||||
const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value <id> "*" --file ${target}\` to scope just that rule to the file, or \`${IMPECCABLE_COMMAND} hooks ignore-file ${target}\` only when the whole file is out of scope for design review (a fixture, a generated artifact, a deliberate demo)`;
|
||||
// behavior. Intentional moves, in order:
|
||||
// 1. **Imperative, not advisory.** "Triage each finding..." beats
|
||||
// "Consider revising...", which the model treats as a soft suggestion.
|
||||
// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The
|
||||
// suppress branch names the calibration examples (demo, fixture,
|
||||
// documented bad design, user-confirmed choice) because the agent now
|
||||
// acts on its own confidence and needs the bar stated.
|
||||
// 3. **Executable ignore path.** The old footer named only the slash
|
||||
// command, which an agent reacting to hook output cannot run; the
|
||||
// hook-admin.mjs invocation is runnable as-is and keeps agents out of
|
||||
// hand-editing config.json.
|
||||
// 4. **Honest provenance.** The --reason is the audit trail; "user
|
||||
// confirmed" appears only when the user actually did.
|
||||
// 5. **Acknowledgement instruction.** Hook output is injected as
|
||||
// developer-role context, so the reply is where the user sees the
|
||||
// resolution, including any ignore the agent persisted.
|
||||
// 6. **Once per session.** The full policy emits on the session's first
|
||||
// fire; later emissions carry the one-line short form (mode 'short').
|
||||
function directiveFooter(opts = {}) {
|
||||
if (opts.mode === 'short') {
|
||||
// No command path here: the session's first emission already gave the
|
||||
// runnable hook-admin.mjs invocation, and restating ~70 chars of absolute
|
||||
// path on every repeat is the duplication this mode exists to cut.
|
||||
return 'Triage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `hook-admin.mjs ignore-value` and disclose them in your reply; unsure, ask in one line.';
|
||||
}
|
||||
return [
|
||||
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
|
||||
'',
|
||||
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
|
||||
'',
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For a finding whose line shows no exact ignore-value command, such as \`side-tab\`, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
|
||||
'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:',
|
||||
'- Real design problem: fix it. Keep intentional design as designed.',
|
||||
`- Confident false positive or sanctioned exception (an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion, a choice the user confirmed): persist the narrowest ignore yourself and disclose it. Run \`${HOOK_ADMIN_COMMAND} ignore-value <rule> "<value>" --reason "<who decided: evidence>"\` with the pair shown on the finding line, or value "*" plus \`--file <path>\` when the line shows none. Write "user confirmed" in a reason only when the user did.`,
|
||||
'- Unsure: leave it as is and ask the user in one line.',
|
||||
`Self-serve ends at ignore-value: \`ignore-file\` and \`ignore-rule\` need the user's explicit approval, and never add an ignore to push a blocked write through. Full suppression ladder: ${IMPECCABLE_COMMAND} hooks.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -1693,6 +1887,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
lastSkip = 'file-missing';
|
||||
continue;
|
||||
}
|
||||
if (!isScanTargetInsideProject(filePath, projectCwd)) {
|
||||
lastSkip = 'outside-project';
|
||||
continue;
|
||||
}
|
||||
|
||||
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
|
||||
if (maxFileBytes > 0) {
|
||||
@@ -1796,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
}
|
||||
}
|
||||
|
||||
// Persist only when the write is earned: fresh findings justify creating
|
||||
// `.impeccable/` (dedup and suppression need it), deferred findings do
|
||||
// too (the Stop deep pass needs the touched-file list to surface them),
|
||||
// and an already-present `.impeccable/` dir marks a project that opted
|
||||
// in. A non-UI edit, or a clean UI edit in a project with no Impeccable
|
||||
// footprint, must be a no-op on disk (issues #344, #305).
|
||||
if (freshGroups.length > 0 || deferredTotal > 0
|
||||
|| (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
|
||||
persistCache(projectCwd, cache);
|
||||
}
|
||||
|
||||
// The session notice flags mutate the cache, so they must settle before
|
||||
// the persist that makes them stick across events.
|
||||
if (freshGroups.length > 0) {
|
||||
const firstGroup = freshGroups[0];
|
||||
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
|
||||
const footerMode = footerModeForSession(cache, sessionId);
|
||||
const text = appendDesignSystemNoteOnce(
|
||||
renderGroupedTemplate(freshGroups, config, {
|
||||
cwd: projectCwd,
|
||||
footer: footerMode,
|
||||
reserveChars: designNoteReserve(scanOptions, cache, sessionId),
|
||||
}),
|
||||
scanOptions, cache, sessionId, config,
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
// Fresh findings always earn the cache write, including creating
|
||||
// `.impeccable/`: dedup, suppression, and the notice flags need it.
|
||||
persistCache(projectCwd, cache);
|
||||
const allFindings = freshGroups.flatMap((group) => group.findings);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -1832,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the ack emission before the persist below: appendDesignSystem-
|
||||
// NoteOnce consumes a session flag, and the flag only sticks when the
|
||||
// write happens after it. Quiet mode emits nothing, so it consumes
|
||||
// nothing. The clean arm mirrors the branch order further down: pending
|
||||
// outranks suppression, suppression outranks clean.
|
||||
let ack = null;
|
||||
if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
|
||||
ack = {
|
||||
kind: 'pending',
|
||||
text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config),
|
||||
};
|
||||
} else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
|
||||
ack = {
|
||||
kind: 'clean',
|
||||
text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config),
|
||||
};
|
||||
}
|
||||
|
||||
// Persist only when the write is earned: deferred findings need the
|
||||
// touched-file list for the Stop deep pass, and an already-present
|
||||
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
|
||||
// clean UI edit in a project with no Impeccable footprint, must be a
|
||||
// no-op on disk (issues #344, #305).
|
||||
if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
|
||||
persistCache(projectCwd, cache);
|
||||
}
|
||||
|
||||
if (detectorThrewAny && !pendingWinner && !cleanWinner) {
|
||||
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
@@ -1840,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
|
||||
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
|
||||
if (ack?.kind === 'pending') {
|
||||
const text = ack.text;
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: payload(text, 'PostToolUse', harness),
|
||||
@@ -1874,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
};
|
||||
}
|
||||
|
||||
if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
|
||||
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
|
||||
if (ack?.kind === 'clean') {
|
||||
const text = ack.text;
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: payload(text, 'PostToolUse', harness),
|
||||
@@ -2023,6 +2251,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
const relForMatch = relativize(filePath, projectCwd);
|
||||
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
// Caches written before this gate existed can still hold out-of-project
|
||||
// paths, so the Stop pass re-checks containment rather than trusting
|
||||
// the per-edit pass to have filtered them.
|
||||
if (!isScanTargetInsideProject(filePath, projectCwd)) continue;
|
||||
|
||||
scanned += 1;
|
||||
let content = '';
|
||||
@@ -2055,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear.
|
||||
persistCache(projectCwd, cache);
|
||||
// A per-edit fire earlier in this session already consumed the footer
|
||||
// flag, so the Stop wall of text carries the one-line short footer.
|
||||
const footerMode = footerModeForSession(cache, sessionId);
|
||||
const text = appendDesignSystemNoteOnce(
|
||||
renderGroupedTemplate(freshGroups, config, {
|
||||
cwd: projectCwd,
|
||||
footer: footerMode,
|
||||
reserveChars: designNoteReserve(scanOptions, cache, sessionId),
|
||||
}),
|
||||
scanOptions, cache, sessionId, config,
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: payload(text, 'Stop', harness),
|
||||
|
||||
@@ -50,9 +50,36 @@ export function normalizeConceptForm(value) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function validateConceptEntry(concept, { existingForms = new Map() } = {}) {
|
||||
export function validateConceptEntry(concept, { existingForms = new Map(), axes = null } = {}) {
|
||||
const errors = [];
|
||||
const id = concept?.id || '(unknown)';
|
||||
|
||||
// Recorded aesthetic axis values. Optional, and absent means the value is
|
||||
// inferred from the system rules instead. Some axes cannot be inferred at all:
|
||||
// depth's keyword probe matched worlds that said "no cast shadow anywhere",
|
||||
// and motion and colour strategy describe properties the rules never state, so
|
||||
// a wave that assigns those has to record them or the assignment is lost.
|
||||
// Validated against the axes definition when the caller supplies it, because a
|
||||
// typo would read as "unrecorded" and silently fall back to a probe that is
|
||||
// known not to work.
|
||||
if (concept?.axes !== undefined && concept.axes !== null) {
|
||||
if (typeof concept.axes !== 'object' || Array.isArray(concept.axes)) {
|
||||
errors.push(`concept ${id} axes must be an object of axis id to value id`);
|
||||
} else if (axes) {
|
||||
const byId = new Map((axes.axes || []).map(axis => [axis.id, axis]));
|
||||
for (const [axisId, valueId] of Object.entries(concept.axes)) {
|
||||
const axis = byId.get(axisId);
|
||||
if (!axis) {
|
||||
errors.push(`concept ${id} names unknown axis "${axisId}"`);
|
||||
} else if (!(axis.values || []).some(value => value.id === valueId)) {
|
||||
errors.push(
|
||||
`concept ${id} axis "${axisId}" has unknown value "${valueId}" `
|
||||
+ `(expected one of ${(axis.values || []).map(v => v.id).join(', ')})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
|
||||
errors.push(`invalid concept id: ${String(concept?.id)}`);
|
||||
}
|
||||
@@ -82,6 +109,18 @@ export function validateConceptEntry(concept, { existingForms = new Map() } = {}
|
||||
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||
errors.push(`concept ${id} must have exactly three structural tags`);
|
||||
}
|
||||
// The slop this world in particular is at risk of. Optional, because 541
|
||||
// entries predate it and none of them are wrong for lacking it. A world built
|
||||
// from posters is at risk of shouting and one built from instruments is at
|
||||
// risk of dead greys; a global detector cannot know which, and the author can.
|
||||
if (concept?.avoid !== undefined) {
|
||||
if (!Array.isArray(concept.avoid)
|
||||
|| concept.avoid.length < 2
|
||||
|| concept.avoid.length > 3
|
||||
|| concept.avoid.some(item => typeof item !== 'string' || item.trim().length < 12 || item.trim().length > 160)) {
|
||||
errors.push(`concept ${id} avoid must be two or three negations of 12–160 characters`);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(concept?.system)
|
||||
|| concept.system.length !== SYSTEM_PREFIXES.length
|
||||
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
||||
|
||||
@@ -2,15 +2,20 @@
|
||||
// the live-mode design-system panel can render. Deterministic, dependency-free.
|
||||
//
|
||||
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
|
||||
// (prose with six canonical H2 sections). When frontmatter is present, it's
|
||||
// (prose with eight canonical H2 sections). When frontmatter is present, it's
|
||||
// exposed on `model.frontmatter` alongside the prose-scraped sections;
|
||||
// consumers can prefer frontmatter values and fall back to prose.
|
||||
|
||||
// Array order is also match precedence: matchCanonicalSection's keyword-contained
|
||||
// pass returns the first entry a heading contains, so reordering this changes
|
||||
// which section an ambiguous heading resolves to.
|
||||
const CANONICAL_SECTIONS = [
|
||||
'Overview',
|
||||
'Colors',
|
||||
'Typography',
|
||||
'Layout',
|
||||
'Elevation',
|
||||
'Shapes',
|
||||
'Components',
|
||||
"Do's and Don'ts",
|
||||
];
|
||||
@@ -115,10 +120,71 @@ function stripInlineYamlComment(s) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// YAML double-quoted scalars process backslash escapes. Stripping the outer
|
||||
// quotes without unescaping leaves them in place, so a nested font family like
|
||||
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
|
||||
// keeps its literal backslashes and never matches the same family in CSS.
|
||||
// The full YAML 1.2 double-quote escape set (spec section 5.7).
|
||||
const YAML_SIMPLE_ESCAPES = {
|
||||
'0': '\0',
|
||||
a: '\x07',
|
||||
b: '\b',
|
||||
t: '\t',
|
||||
n: '\n',
|
||||
v: '\v',
|
||||
f: '\f',
|
||||
r: '\r',
|
||||
e: '\x1b',
|
||||
' ': ' ',
|
||||
'"': '"',
|
||||
'/': '/',
|
||||
'\\': '\\',
|
||||
N: '\u0085',
|
||||
_: '\u00a0',
|
||||
L: '\u2028',
|
||||
P: '\u2029',
|
||||
};
|
||||
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
|
||||
|
||||
function unescapeYamlDoubleQuoted(body) {
|
||||
let out = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch !== '\\' || i === body.length - 1) {
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
const next = body[i + 1];
|
||||
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
|
||||
out += YAML_SIMPLE_ESCAPES[next];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
|
||||
// literal rather than corrupting the rest of the scalar.
|
||||
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
|
||||
if (hexLen) {
|
||||
const hex = body.slice(i + 2, i + 2 + hexLen);
|
||||
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
|
||||
if (codePoint >= 0 && codePoint <= 0x10ffff) {
|
||||
out += String.fromCodePoint(codePoint);
|
||||
i += 1 + hexLen;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseScalar(raw) {
|
||||
const s = raw.trim();
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||
return unescapeYamlDoubleQuoted(s.slice(1, -1));
|
||||
}
|
||||
// Single-quoted YAML escapes only the quote itself, by doubling it.
|
||||
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
|
||||
return s.slice(1, -1).split("''").join("'");
|
||||
}
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
@@ -330,17 +396,16 @@ function extractOverview(section) {
|
||||
if (!section) return null;
|
||||
const text = section.lines.join('\n');
|
||||
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
|
||||
const keyChars = [];
|
||||
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
|
||||
if (keyCharMatch) {
|
||||
for (const line of keyCharMatch[1].split('\n')) {
|
||||
const m = line.match(/^\s*[-*]\s+(.+)$/);
|
||||
if (m) keyChars.push(stripBold(m[1].trim()));
|
||||
}
|
||||
}
|
||||
const keyChars = keyCharMatch
|
||||
? collectBullets(keyCharMatch[1].split('\n')).map((bullet) => stripBold(bullet.trim()))
|
||||
: [];
|
||||
const prose = keyCharMatch
|
||||
? text.slice(0, keyCharMatch.index) + text.slice(keyCharMatch.index + keyCharMatch[0].length)
|
||||
: text;
|
||||
|
||||
// Philosophy paragraphs: everything that isn't a rule header or key-char block
|
||||
const paragraphs = collectParagraphs(section.lines).filter(
|
||||
const paragraphs = collectParagraphs(prose.split('\n')).filter(
|
||||
(p) =>
|
||||
!p.startsWith('**Creative North Star') &&
|
||||
!p.startsWith('**Key Characteristics')
|
||||
@@ -602,11 +667,19 @@ function parseTypeBullet(bullet) {
|
||||
};
|
||||
}
|
||||
|
||||
function extractElevation(section) {
|
||||
function extractGuidance(section) {
|
||||
if (!section) return null;
|
||||
const subs = splitSubsections(section.lines);
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
description: collectParagraphs(subs[0].lines).join(' ') || null,
|
||||
rules: extractNamedRules(section.lines),
|
||||
};
|
||||
}
|
||||
|
||||
const description = collectParagraphs(subs[0].lines).join(' ') || null;
|
||||
function extractElevation(section) {
|
||||
const guidance = extractGuidance(section);
|
||||
if (!guidance) return null;
|
||||
|
||||
const shadows = [];
|
||||
const seen = new Set();
|
||||
@@ -631,12 +704,7 @@ function extractElevation(section) {
|
||||
for (const inline of extractInlineShadows(b)) dedupe(inline);
|
||||
}
|
||||
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
description,
|
||||
shadows,
|
||||
rules: extractNamedRules(section.lines),
|
||||
};
|
||||
return { ...guidance, shadows };
|
||||
}
|
||||
|
||||
function extractInlineShadows(text) {
|
||||
@@ -768,6 +836,15 @@ function extractDosDonts(section) {
|
||||
|
||||
// ---------- Coverage assessment ----------
|
||||
|
||||
// Sections whose model is description-plus-rules only (see extractGuidance).
|
||||
const guidanceCoverage = (guidance) =>
|
||||
guidance
|
||||
? {
|
||||
description: Boolean(guidance.description),
|
||||
rules: guidance.rules.length,
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
function assessCoverage(model) {
|
||||
const report = {};
|
||||
|
||||
@@ -796,6 +873,8 @@ function assessCoverage(model) {
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.layout = guidanceCoverage(model.layout);
|
||||
|
||||
report.elevation = model.elevation
|
||||
? {
|
||||
shadows: model.elevation.shadows.length,
|
||||
@@ -804,6 +883,8 @@ function assessCoverage(model) {
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.shapes = guidanceCoverage(model.shapes);
|
||||
|
||||
report.components = model.components
|
||||
? {
|
||||
count: model.components.components.length,
|
||||
@@ -833,7 +914,9 @@ export function parseDesignMd(md) {
|
||||
overview: extractOverview(sections['Overview']),
|
||||
colors: extractColors(sections['Colors']),
|
||||
typography: extractTypography(sections['Typography']),
|
||||
layout: extractGuidance(sections['Layout']),
|
||||
elevation: extractElevation(sections['Elevation']),
|
||||
shapes: extractGuidance(sections['Shapes']),
|
||||
components: extractComponents(sections['Components']),
|
||||
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
|
||||
};
|
||||
|
||||
@@ -206,10 +206,10 @@ function parseIgnoreColor(value) {
|
||||
if (rgb) {
|
||||
const parts = splitColorArgs(rgb[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const r = parseRgbChannel(parts[0]);
|
||||
const g = parseRgbChannel(parts[1]);
|
||||
const b = parseRgbChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
const r = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.rgb);
|
||||
const g = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.rgb);
|
||||
const b = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.rgb);
|
||||
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
|
||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
@@ -218,10 +218,10 @@ function parseIgnoreColor(value) {
|
||||
if (hsl) {
|
||||
const parts = splitColorArgs(hsl[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const h = parseHueChannel(parts[0]);
|
||||
const s = parsePercentChannel(parts[1]);
|
||||
const l = parsePercentChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
const h = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.hue);
|
||||
const s = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.percent);
|
||||
const l = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.percent);
|
||||
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
|
||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||
return hslToRgb(h, s, l, a);
|
||||
}
|
||||
@@ -230,18 +230,13 @@ function parseIgnoreColor(value) {
|
||||
}
|
||||
|
||||
function parseHexIgnoreColor(hex) {
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
const expanded = hex.length <= 4
|
||||
? [...hex].map((digit) => digit.repeat(2)).join('')
|
||||
: hex;
|
||||
const [r, g, b, alpha = 255] = expanded
|
||||
.match(/../g)
|
||||
.map((channel) => Number.parseInt(channel, 16));
|
||||
return { r, g, b, a: alpha / 255 };
|
||||
}
|
||||
|
||||
function splitColorArgs(body) {
|
||||
@@ -259,47 +254,34 @@ function splitColorArgs(body) {
|
||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||
}
|
||||
|
||||
function parseRgbChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const scaled = match[2] ? value * 2.55 : value;
|
||||
if (scaled < 0 || scaled > 255) return null;
|
||||
return Math.round(scaled);
|
||||
}
|
||||
const CSS_NUMBER_RE = /^(-?\d*\.?\d+)(%|deg|rad|turn|grad)?$/;
|
||||
const identity = (value) => value;
|
||||
const COLOR_CHANNEL_FORMATS = {
|
||||
rgb: { units: { '': identity, '%': (value) => value * 2.55 }, min: 0, max: 255, round: true },
|
||||
alpha: { units: { '': identity, '%': (value) => value / 100 }, min: 0, max: 1 },
|
||||
hue: {
|
||||
units: {
|
||||
'': identity,
|
||||
deg: identity,
|
||||
rad: (value) => value * (180 / Math.PI),
|
||||
turn: (value) => value * 360,
|
||||
grad: (value) => value * 0.9,
|
||||
},
|
||||
},
|
||||
percent: { units: { '%': (value) => value / 100 }, min: 0, max: 1 },
|
||||
};
|
||||
|
||||
function parseAlphaChannel(raw) {
|
||||
function parseColorChannel(raw, { units, min = -Infinity, max = Infinity, round = false }) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
const match = text.match(CSS_NUMBER_RE);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const alpha = match[2] ? value / 100 : value;
|
||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
||||
}
|
||||
|
||||
function parseHueChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const unit = match[2] || 'deg';
|
||||
if (unit === 'turn') return value * 360;
|
||||
if (unit === 'rad') return value * (180 / Math.PI);
|
||||
if (unit === 'grad') return value * 0.9;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePercentChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return value >= 0 && value <= 100 ? value / 100 : null;
|
||||
const convert = units[match[2] || ''];
|
||||
if (!convert) return null;
|
||||
const number = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(number)) return null;
|
||||
const value = convert(number);
|
||||
if (value < min || value > max) return null;
|
||||
return round ? Math.round(value) : value;
|
||||
}
|
||||
|
||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* within the first ~300 characters — catches non-git projects.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) {
|
||||
|
||||
function isGitIgnored(absPath, cwd) {
|
||||
try {
|
||||
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
|
||||
// argv form, never a shell: this runs on every file the live-mode source
|
||||
// walk reaches, so a hostile filename embedding $(...) or backticks must
|
||||
// not be interpretable (issue #476). JSON.stringify is not shell quoting.
|
||||
execFileSync('git', ['check-ignore', '--quiet', absPath], {
|
||||
cwd,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export function browserOpenCommand(url, {
|
||||
platform = process.platform,
|
||||
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
|
||||
} = {}) {
|
||||
if (platform === 'darwin') return { command: 'open', args: [url] };
|
||||
if (platform === 'win32') return { command: comspec, args: ['/c', 'start', '', url] };
|
||||
return { command: 'xdg-open', args: [url] };
|
||||
}
|
||||
|
||||
export function openSystemBrowser(url, {
|
||||
platform = process.platform,
|
||||
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
|
||||
spawnImpl = spawn,
|
||||
} = {}) {
|
||||
const { command, args } = browserOpenCommand(url, { platform, comspec });
|
||||
try {
|
||||
const child = spawnImpl(command, args, { stdio: 'ignore', detached: true });
|
||||
child.on('error', () => {});
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -96,31 +96,38 @@ function* rank(items, input, idFor = item => item.id) {
|
||||
.map(entry => entry.item);
|
||||
}
|
||||
|
||||
// Two independent exclusions, and either one is enough to hold a world back.
|
||||
// Rating grades quality: a 3-star earns a second ticket, a 1-star marginal keep
|
||||
// leaves the pool. Breadth says whether a world can serve an arbitrary build at
|
||||
// all, so a niche world leaves however good it is, keeping its approval for
|
||||
// direct briefs. Breadth was split out of rating because the only way to hold a
|
||||
// narrow world back used to be calling it marginal, which made "excellent but
|
||||
// narrow" unrecordable and corrupted ratings as a calibration signal.
|
||||
// Rating sets how many tickets a world holds; breadth decides whether it draws
|
||||
// at all. A niche world leaves the pool however good it is, keeping its approval
|
||||
// for direct briefs. Breadth was split out of rating because the only way to
|
||||
// hold a narrow world back used to be calling it marginal, which made "excellent
|
||||
// but narrow" unrecordable and corrupted ratings as a calibration signal.
|
||||
//
|
||||
// Two tickets for a 3-star, one for everything else, was too sharp. Measured
|
||||
// against the catalog as it stood: 3-star worlds absorbed 57% of the graphic
|
||||
// draw from 65 of 163 eligible worlds, 46% of atmosphere from 13 of 43, and
|
||||
// 75% of interaction from 15 of 25. The reviewer's complaint, that the same
|
||||
// worlds keep coming back, is what a rating multiplier does to a pool whose
|
||||
// thinnest tier holds 25 worlds.
|
||||
//
|
||||
// So a 3-star no longer outdraws a 2-star, and a 1-star draws at half rather
|
||||
// than not at all. A marginal keep is still worth showing sometimes: the
|
||||
// judgement it records is "narrow or unexceptional", not "wrong", and excluding
|
||||
// it entirely made a rating do a job breadth already does properly.
|
||||
const RATING_TICKETS = { 1: 1, 2: 2, 3: 2 };
|
||||
const ticketsForRating = rating => RATING_TICKETS[rating] ?? 2;
|
||||
|
||||
function challengerTickets(pool) {
|
||||
return pool.flatMap(concept => {
|
||||
const rating = concept.review?.rating;
|
||||
if (rating === 1 || concept.review?.breadth === 'niche') return [];
|
||||
return rating === 3
|
||||
? [{ concept, ticket: 0 }, { concept, ticket: 1 }]
|
||||
: [{ concept, ticket: 0 }];
|
||||
if (concept.review?.breadth === 'niche') return [];
|
||||
return Array.from({ length: ticketsForRating(concept.review?.rating) },
|
||||
(_, ticket) => ({ concept, ticket }));
|
||||
});
|
||||
}
|
||||
|
||||
function compositionTickets(pool) {
|
||||
return pool.flatMap(composition => {
|
||||
const rating = composition.review?.rating;
|
||||
if (rating === 1) return [];
|
||||
return rating === 3
|
||||
? [{ composition, ticket: 0 }, { composition, ticket: 1 }]
|
||||
: [{ composition, ticket: 0 }];
|
||||
});
|
||||
return pool.flatMap(composition => Array.from(
|
||||
{ length: ticketsForRating(composition.review?.rating) },
|
||||
(_, ticket) => ({ composition, ticket })));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -117,6 +117,23 @@ export function checkDesignDrift({ designPath, projectRoot, threshold = 25 }) {
|
||||
* a section can be absent because it never applied, so this is reported as a
|
||||
* documentation gap for a human to judge, never as an error.
|
||||
*/
|
||||
function hasCoverageValue(value) {
|
||||
if (Array.isArray(value)) return value.some(hasCoverageValue);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.values(value).some(hasCoverageValue);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 && !/^(?:\[\s*\]|\{\s*\})$/.test(trimmed);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const SEED_DESIGN_MARKERS = ['/', '$'].map((prefix) =>
|
||||
'<!-- SEED: established with the user before implementation; '
|
||||
+ `re-run ${prefix}impeccable document once there's code to capture the actual tokens and components. -->`
|
||||
);
|
||||
|
||||
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
||||
if (!design || typeof parseDesignMd !== 'function') return [];
|
||||
let model;
|
||||
@@ -125,8 +142,12 @@ export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const missing = ['colors', 'typography', 'components']
|
||||
.filter((section) => !model[section]);
|
||||
const isSeed = SEED_DESIGN_MARKERS.some((marker) => design.includes(marker));
|
||||
const requiredSections = isSeed
|
||||
? ['colors', 'typography']
|
||||
: ['colors', 'typography', 'components'];
|
||||
const missing = requiredSections
|
||||
.filter((section) => !model[section] && !hasCoverageValue(model.frontmatter?.[section]));
|
||||
if (!missing.length) return [];
|
||||
return [finding({
|
||||
id: 'design-md-coverage',
|
||||
@@ -223,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/;
|
||||
// * bundle-relative: node ".agents/.../hook.mjs"
|
||||
// * legacy unquoted: node .claude/.../hook.mjs
|
||||
// * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical)
|
||||
// * absolute: node "/Users/.../hook.mjs" (user-level installs)
|
||||
// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since
|
||||
// the shell-injection fix; older installs double-quote)
|
||||
// * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs"
|
||||
// A quoted path wins; the guard's two occurrences are identical, so the first
|
||||
// quoted match is the path. Otherwise fall back to the whitespace/metachar-
|
||||
@@ -234,6 +256,12 @@ function hookScriptTokenFrom(command) {
|
||||
if (!HOOK_MARKER.test(str)) return null;
|
||||
const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/);
|
||||
if (quoted) return quoted[1];
|
||||
// A path containing an apostrophe serializes as '\'' inside single quotes;
|
||||
// no regex reassembles that, and the bare fallback would misread a fragment
|
||||
// of it, so return null: the caller never asserts on a path it can't parse.
|
||||
if (str.includes("'\\''")) return null;
|
||||
const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/);
|
||||
if (singleQuoted) return singleQuoted[1];
|
||||
const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/);
|
||||
return bare ? bare[1] : null;
|
||||
}
|
||||
|
||||
@@ -97,23 +97,20 @@
|
||||
return { value: c.value, label: c.label };
|
||||
});
|
||||
|
||||
const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions'];
|
||||
const LIVE_UI_SURFACES = [
|
||||
{ key: 'global-bottom-bar', ids: [PREFIX + '-global-bar', PREFIX + '-global-bar-brand', PREFIX + '-pick-toggle', PREFIX + '-insert-toggle', PREFIX + '-detect-toggle', PREFIX + '-detect-badge', PREFIX + '-design-toggle', PREFIX + '-page-chat', PREFIX + '-page-chat-input', PREFIX + '-page-chat-voice', PREFIX + '-page-chat-send'] },
|
||||
{ key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] },
|
||||
{ key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] },
|
||||
{ key: 'action-picker', ids: [PREFIX + '-picker'] },
|
||||
{ key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] },
|
||||
{ key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] },
|
||||
{ key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] },
|
||||
{ key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] },
|
||||
{ key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] },
|
||||
{ key: 'insert-mode-chrome', ids: [PREFIX + '-insert-line', PREFIX + '-insert-placeholder', PREFIX + '-placeholder-resize', PREFIX + '-insert-input', PREFIX + '-insert-voice', PREFIX + '-insert-create', PREFIX + '-insert-create-tooltip'] },
|
||||
{ key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] },
|
||||
{ key: 'design-system-panel', ids: [PREFIX + '-design-host'] },
|
||||
{ key: 'toasts-and-errors', ids: [PREFIX + '-toast', PREFIX + '-mount-error'] },
|
||||
{ key: 'css-isolation-boundary', ids: [PREFIX + '-root'] },
|
||||
];
|
||||
// The Live chrome inventory (which surfaces exist, and the element ids each
|
||||
// one owns) comes from the canonical source, skill/scripts/live/ui-surfaces.mjs,
|
||||
// which the /live.js assembler serializes into these globals alongside the
|
||||
// token/port/vocabulary. This file is served raw and injected as a classic
|
||||
// script, so it cannot import that module; the private impeccable-site repo
|
||||
// imports it directly to check its Live UI lab holds a snapshot for every
|
||||
// surface, which only works while the list has exactly one definition.
|
||||
// Add a surface in ui-surfaces.mjs, not here.
|
||||
const LIVE_CHROME_MOUNT_CONTRACT = Array.isArray(window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__)
|
||||
? window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__
|
||||
: ['root', 'transport', 'state', 'actions'];
|
||||
const LIVE_UI_SURFACES = Array.isArray(window.__IMPECCABLE_LIVE_UI_SURFACES__)
|
||||
? window.__IMPECCABLE_LIVE_UI_SURFACES__
|
||||
: [];
|
||||
const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))];
|
||||
|
||||
//
|
||||
@@ -3766,7 +3763,10 @@
|
||||
const container = copyEditContainerContext(contextElement);
|
||||
if (container) for (const op of ops) op.container = container;
|
||||
try {
|
||||
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', {
|
||||
// Token in the query string as well as the body: the URL token is what
|
||||
// authorizes the CORS preflight when the page runs on a non-loopback
|
||||
// dev host (ddev, Valet), since the preflight carries no request body.
|
||||
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -7150,7 +7150,10 @@
|
||||
console.debug('[impeccable] Dropped optional live event:', err);
|
||||
return null;
|
||||
}
|
||||
const doSend = () => fetch('http://localhost:' + PORT + '/events', {
|
||||
// Token in the query string as well as the body: the URL token is what
|
||||
// authorizes the CORS preflight when the page runs on a non-loopback
|
||||
// dev host (ddev, Valet), since the preflight carries no request body.
|
||||
const doSend = () => fetch('http://localhost:' + PORT + '/events?token=' + encodeURIComponent(TOKEN), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(msg),
|
||||
@@ -11969,7 +11972,9 @@ void main() {
|
||||
rules: [
|
||||
...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
|
||||
...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
|
||||
...(md.layout?.rules || []).map((r) => ({ ...r, section: 'layout' })),
|
||||
...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
|
||||
...(md.shapes?.rules || []).map((r) => ({ ...r, section: 'shapes' })),
|
||||
],
|
||||
dos: md.dosDonts?.dos || [],
|
||||
donts: md.dosDonts?.donts || [],
|
||||
|
||||
@@ -14,10 +14,12 @@ import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
const BATCH_OP_TEXT_LIMIT = 240;
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
||||
const repairLines = batch?.repair ? [
|
||||
const compactBatch = compactBatchForPrompt(batch);
|
||||
const repairLines = compactBatch.repair ? [
|
||||
'',
|
||||
'Repair mode:',
|
||||
'- The previous Apply attempt changed source, but validation failed.',
|
||||
@@ -28,7 +30,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
||||
'- If failures or candidates show edited text is also a lookup key, update coupled count, animation, icon, image, asset, style, or metadata keys in the current source, or fail that entry without partial edits.',
|
||||
'- Keep failed and notes as arrays.',
|
||||
'- Return the same canonical JSON shape after repair.',
|
||||
JSON.stringify(batch.repair, null, 2),
|
||||
JSON.stringify(compactBatch.repair, null, 2),
|
||||
] : [];
|
||||
return [
|
||||
'You are the Impeccable staged copy-edit batch applier.',
|
||||
@@ -80,7 +82,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
||||
...repairLines,
|
||||
'',
|
||||
'Staged copy-edit batch:',
|
||||
JSON.stringify(compactBatchForPrompt(batch), null, 2),
|
||||
JSON.stringify(compactBatch, null, 2),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -292,7 +294,7 @@ function readManualEditValidationScript(cwd) {
|
||||
function compactBatchForPrompt(batch) {
|
||||
return {
|
||||
pageUrl: batch?.pageUrl || null,
|
||||
repair: batch?.repair || undefined,
|
||||
repair: compactBatchRepair(batch?.repair),
|
||||
entries: (batch?.entries || []).map((entry) => ({
|
||||
id: entry.id,
|
||||
pageUrl: entry.pageUrl,
|
||||
@@ -300,7 +302,71 @@ function compactBatchForPrompt(batch) {
|
||||
element: compactContextForBatch(entry.element),
|
||||
ops: (entry.ops || []).map(compactBatchOp),
|
||||
})),
|
||||
candidates: batch?.candidates || [],
|
||||
candidates: compactBatchCandidates(batch?.candidates),
|
||||
};
|
||||
}
|
||||
|
||||
function compactBatchRepair(repair) {
|
||||
if (!repair || typeof repair !== 'object') return undefined;
|
||||
return {
|
||||
status: compactBatchString(repair.status),
|
||||
attempt: normalizeOptionalBatchNumber(repair.attempt),
|
||||
attempts: normalizeOptionalBatchNumber(repair.attempts),
|
||||
maxAttempts: normalizeOptionalBatchNumber(repair.maxAttempts),
|
||||
reason: compactBatchString(repair.reason),
|
||||
transactionId: compactBatchString(repair.transactionId),
|
||||
pageUrl: compactBatchString(repair.pageUrl),
|
||||
failures: compactBatchDiagnostics(repair.failures),
|
||||
files: compactBatchStringList(repair.files, 20),
|
||||
};
|
||||
}
|
||||
|
||||
function compactBatchDiagnostics(items, depth = 0) {
|
||||
if (!Array.isArray(items)) return undefined;
|
||||
return items.slice(0, 12).map((item) => ({
|
||||
entryId: compactBatchString(item?.entryId || item?.id),
|
||||
reason: compactBatchString(item?.reason || item?.kind),
|
||||
detail: compactBatchString(item?.detail),
|
||||
message: compactBatchString(item?.message),
|
||||
file: compactBatchString(item?.file || item?.relativeFile),
|
||||
line: normalizeOptionalBatchNumber(item?.line),
|
||||
ref: compactBatchString(item?.ref),
|
||||
marker: compactBatchString(item?.marker),
|
||||
files: compactBatchStringList(item?.files, 8),
|
||||
candidates: depth < 2 ? compactBatchSourceMatches(item?.candidates, 8) : undefined,
|
||||
failures: depth < 2 ? compactBatchDiagnostics(item?.failures, depth + 1) : undefined,
|
||||
checks: depth < 2 ? compactBatchDiagnostics(item?.checks, depth + 1) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function compactBatchCandidates(candidates) {
|
||||
return (Array.isArray(candidates) ? candidates : [])
|
||||
.slice(0, 24)
|
||||
.map((candidate) => ({
|
||||
entryId: compactBatchString(candidate?.entryId),
|
||||
ref: compactBatchString(candidate?.ref),
|
||||
sourceHint: compactBatchSourceMatch(candidate?.sourceHint),
|
||||
textMatches: compactBatchSourceMatches(candidate?.textMatches, 8),
|
||||
objectKeyMatches: compactBatchSourceMatches(candidate?.objectKeyMatches, 8),
|
||||
contextTextMatches: compactBatchSourceMatches(candidate?.contextTextMatches, 8),
|
||||
locatorMatches: compactBatchSourceMatches(candidate?.locatorMatches, 6),
|
||||
}));
|
||||
}
|
||||
|
||||
function compactBatchSourceMatches(matches, limit) {
|
||||
if (!Array.isArray(matches)) return undefined;
|
||||
return matches.slice(0, limit).map(compactBatchSourceMatch).filter(Boolean);
|
||||
}
|
||||
|
||||
function compactBatchSourceMatch(match) {
|
||||
if (!match || typeof match !== 'object') return null;
|
||||
return {
|
||||
file: compactBatchString(match.relativeFile || match.file),
|
||||
line: normalizeBatchNumber(match.line),
|
||||
column: normalizeBatchNumber(match.column),
|
||||
kind: compactBatchString(match.kind),
|
||||
reason: compactBatchString(match.reason || match.kind),
|
||||
status: compactBatchString(match.status),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,25 +377,77 @@ function compactBatchOp(op) {
|
||||
contextRef: op.contextRef,
|
||||
tag: op.tag,
|
||||
elementId: op.elementId,
|
||||
classes: op.classes,
|
||||
classes: compactBatchStringList(op.classes, 24),
|
||||
originalText: op.originalText,
|
||||
newText: op.newText,
|
||||
deleted: op.deleted === true || undefined,
|
||||
sourceHint: op.sourceHint,
|
||||
sourceHint: normalizeBatchSourceHint(op.sourceHint),
|
||||
leaf: compactContextForBatch(op.leaf),
|
||||
nearbyEditableTexts: Array.isArray(op.nearbyEditableTexts) ? op.nearbyEditableTexts.slice(0, 8) : [],
|
||||
nearbyEditableTexts: compactNearbyBatchTexts(op.nearbyEditableTexts),
|
||||
container: compactContextForBatch(op.container),
|
||||
contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 12) : [],
|
||||
contextHints: compactBatchStringList(op.contextHints, 12),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBatchSourceHint(hint) {
|
||||
if (!hint || typeof hint !== 'object') return null;
|
||||
let line = normalizeBatchNumber(hint.line);
|
||||
let column = normalizeBatchNumber(hint.column);
|
||||
if ((line === null || column === null) && typeof hint.loc === 'string') {
|
||||
const match = hint.loc.match(/^(\d+)(?::(\d+))?/);
|
||||
if (match) {
|
||||
line = Number(match[1]);
|
||||
if (match[2]) column = Number(match[2]);
|
||||
}
|
||||
}
|
||||
return {
|
||||
file: compactBatchString(hint.file) || '',
|
||||
loc: compactBatchString(hint.loc) || '',
|
||||
line,
|
||||
column,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBatchNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function normalizeOptionalBatchNumber(value) {
|
||||
const number = normalizeBatchNumber(value);
|
||||
return number === null ? undefined : number;
|
||||
}
|
||||
|
||||
function compactNearbyBatchTexts(items) {
|
||||
return (Array.isArray(items) ? items : [])
|
||||
.slice(0, 8)
|
||||
.map((item) => typeof item === 'string' ? { text: truncate(item, BATCH_OP_TEXT_LIMIT) } : {
|
||||
ref: compactBatchString(item?.ref),
|
||||
tag: compactBatchString(item?.tag),
|
||||
classes: compactBatchStringList(item?.classes, 24),
|
||||
text: compactBatchString(item?.text),
|
||||
});
|
||||
}
|
||||
|
||||
function compactBatchStringList(items, limit) {
|
||||
return (Array.isArray(items) ? items : [])
|
||||
.slice(0, limit)
|
||||
.filter((item) => typeof item === 'string')
|
||||
.map((item) => truncate(item, BATCH_OP_TEXT_LIMIT));
|
||||
}
|
||||
|
||||
function compactBatchString(value) {
|
||||
return typeof value === 'string' ? truncate(value, BATCH_OP_TEXT_LIMIT) : undefined;
|
||||
}
|
||||
|
||||
function compactContextForBatch(value) {
|
||||
if (!value || typeof value !== 'object') return value || null;
|
||||
return {
|
||||
ref: value.ref,
|
||||
tagName: value.tagName,
|
||||
id: value.id,
|
||||
classes: value.classes,
|
||||
ref: compactBatchString(value.ref),
|
||||
tagName: compactBatchString(value.tagName),
|
||||
id: compactBatchString(value.id),
|
||||
classes: compactBatchStringList(value.classes, 24),
|
||||
textContent: truncate(value.textContent, 900),
|
||||
outerHTML: truncate(stripLiveRuntimeHtml(value.outerHTML), 1800),
|
||||
};
|
||||
@@ -470,12 +588,11 @@ function runClaude(prompt, { cwd, env, resultPath, logPath, timeoutMs = DEFAULT_
|
||||
if (env.IMPECCABLE_LIVE_COPY_AGENT_MODEL) {
|
||||
args.push('--model', env.IMPECCABLE_LIVE_COPY_AGENT_MODEL);
|
||||
}
|
||||
args.push(prompt);
|
||||
// Forward env as-is so CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY flow
|
||||
// through. On macOS, `claude /login` stores creds in the Keychain, which a
|
||||
// non-TTY subprocess cannot read; setting CLAUDE_CODE_OAUTH_TOKEN (via
|
||||
// `claude setup-token`) is the supported headless auth path.
|
||||
return runAgentProcess('claude', args, '', { cwd, env, logPath, timeoutMs, mirrorOutputPath: resultPath });
|
||||
return runAgentProcess('claude', args, prompt, { cwd, env, logPath, timeoutMs, mirrorOutputPath: resultPath });
|
||||
}
|
||||
|
||||
function runAgentProcess(command, args, stdin, { cwd, env, logPath, timeoutMs, mirrorOutputPath }) {
|
||||
|
||||
@@ -689,16 +689,24 @@ function isLoopbackOrigin(origin) {
|
||||
function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
return (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${state.port}`);
|
||||
// Loopback-restricted CORS. Reflect the caller's Origin only when it is a
|
||||
// loopback origin, always paired with `Vary: Origin` so an intermediary
|
||||
// cache never serves a response authorized for one origin to another. A
|
||||
// remote page (e.g. https://evil.example probing the port from a tab open
|
||||
// on the same machine) gets no Access-Control-Allow-Origin, so its
|
||||
// JS-initiated fetch cannot read any response. Requests with no Origin
|
||||
// header (script tags, curl, the agent's own fetches) are not subject to
|
||||
// CORS and keep working; no ACAO header is needed for them.
|
||||
// Token-or-loopback CORS. Reflect the caller's Origin when it is a
|
||||
// loopback origin OR the request carries the valid session token, always
|
||||
// paired with `Vary: Origin` so an intermediary cache never serves a
|
||||
// response authorized for one origin to another. A remote page (e.g.
|
||||
// https://evil.example probing the port from a tab open on the same
|
||||
// machine) has no token and gets no Access-Control-Allow-Origin, so its
|
||||
// JS-initiated fetch cannot read any response. The token branch exists for
|
||||
// dev servers on non-localhost loopback aliases (ddev's *.ddev.site,
|
||||
// Valet's *.test, hosts-file entries): the injected classic <script src>
|
||||
// delivers the token to the page regardless of origin, every overlay
|
||||
// request carries it in the query string (preflights included, since
|
||||
// OPTIONS hits the same URL), and a token bearer is already fully
|
||||
// authorized on every route — the token is the security boundary, not the
|
||||
// origin. Requests with no Origin header (script tags, curl, the agent's
|
||||
// own fetches) are not subject to CORS and keep working; no ACAO header
|
||||
// is needed for them.
|
||||
const origin = req.headers.origin;
|
||||
if (origin && isLoopbackOrigin(origin)) {
|
||||
if (origin && (isLoopbackOrigin(origin) || url.searchParams.get('token') === state.token)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
@@ -865,7 +873,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
// { present, parsed, sidecar, hasMd, hasSidecar,
|
||||
// mdNewerThanJson, parseError?, sidecarError? }
|
||||
// - parsed: output of parseDesignMd (frontmatter
|
||||
// + six canonical sections) when DESIGN.md exists.
|
||||
// + the canonical sections) when DESIGN.md exists.
|
||||
// - sidecar: .impeccable/design.json contents when present.
|
||||
// Expected shape: schemaVersion 2, carrying
|
||||
// extensions + components + narrative.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* node live.mjs --help
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -316,11 +316,17 @@ function globToRegex(pattern) {
|
||||
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
// argv form, never a shell: string interpolation into double quotes would
|
||||
// let a `"` or `$(...)` in any future caller's arg escape into the shell
|
||||
// (issue #476).
|
||||
return execFileSync(process.execPath, [scriptPath, ...args], {
|
||||
encoding: 'utf-8',
|
||||
cwd: options.cwd || process.cwd(),
|
||||
timeout: 15_000,
|
||||
});
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
// execFileSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs';
|
||||
|
||||
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
|
||||
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
|
||||
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
|
||||
@@ -32,7 +34,20 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re
|
||||
}));
|
||||
}
|
||||
|
||||
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', appRoot = null, parts }) {
|
||||
export function assembleLiveBrowserScript({
|
||||
token,
|
||||
port,
|
||||
vocabulary,
|
||||
commandPrefix = '/',
|
||||
appRoot = null,
|
||||
parts,
|
||||
// Defaulted rather than threaded through live-server.mjs: the browser bundle
|
||||
// must always carry the canonical inventory, and a default makes that true by
|
||||
// construction instead of by every caller remembering to pass it. Overridable
|
||||
// so tests can assemble with a stand-in.
|
||||
uiSurfaces = LIVE_UI_SURFACES,
|
||||
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
|
||||
}) {
|
||||
const prelude =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${port};\n` +
|
||||
@@ -44,7 +59,14 @@ export function assembleLiveBrowserScript({ token, port, vocabulary, commandPref
|
||||
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
|
||||
// Canonical command vocabulary (values + labels + icons). live-browser.js
|
||||
// builds its action picker from this instead of an inline copy.
|
||||
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n`;
|
||||
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n` +
|
||||
// Canonical Live chrome inventory from live/ui-surfaces.mjs. live-browser.js
|
||||
// is a classic script and cannot import an ES module at runtime, so the list
|
||||
// is serialized here and read off the global there. Node consumers (this
|
||||
// repo's tests, the impeccable-site Live UI lab) import the module directly,
|
||||
// which is what keeps the two from drifting.
|
||||
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
|
||||
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
|
||||
|
||||
const body = parts.map((part) => {
|
||||
const file = part.file || path.basename(part.path || '');
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* Framework-neutral Impeccable live chrome contract.
|
||||
*
|
||||
* The production browser bundle is intentionally plain DOM so Svelte, React,
|
||||
* Vue, and static adapters can all mount the same chrome. This module is the
|
||||
* testable contract/inventory for that bundle; live-browser.js mirrors these
|
||||
* values at runtime because it is served as a standalone script.
|
||||
*/
|
||||
|
||||
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
|
||||
'root',
|
||||
'transport',
|
||||
'state',
|
||||
'actions',
|
||||
]);
|
||||
|
||||
export const LIVE_UI_SURFACES = Object.freeze([
|
||||
{
|
||||
key: 'global-bottom-bar',
|
||||
ids: [
|
||||
'impeccable-live-global-bar',
|
||||
'impeccable-live-global-bar-brand',
|
||||
'impeccable-live-pick-toggle',
|
||||
'impeccable-live-insert-toggle',
|
||||
'impeccable-live-detect-toggle',
|
||||
'impeccable-live-detect-badge',
|
||||
'impeccable-live-design-toggle',
|
||||
'impeccable-live-page-chat',
|
||||
'impeccable-live-page-chat-input',
|
||||
'impeccable-live-page-chat-voice',
|
||||
],
|
||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
|
||||
},
|
||||
{
|
||||
key: 'pending-copy-edit-dock',
|
||||
ids: ['impeccable-live-pending-dock'],
|
||||
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
|
||||
},
|
||||
{
|
||||
key: 'element-selection-chrome',
|
||||
ids: [
|
||||
'impeccable-live-highlight',
|
||||
'impeccable-live-tooltip',
|
||||
'impeccable-live-bar',
|
||||
'impeccable-live-selection-pill',
|
||||
'impeccable-live-input',
|
||||
'impeccable-live-configure-voice',
|
||||
'impeccable-live-configure-bar-tooltip',
|
||||
],
|
||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
|
||||
},
|
||||
{
|
||||
key: 'action-picker',
|
||||
ids: ['impeccable-live-picker'],
|
||||
states: ['closed', 'open', 'option-hover', 'option-focus'],
|
||||
},
|
||||
{
|
||||
key: 'edit-chrome',
|
||||
ids: ['impeccable-live-edit-badge'],
|
||||
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
|
||||
},
|
||||
{
|
||||
key: 'generating-row',
|
||||
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
|
||||
states: ['action-label', 'animated-dots', 'generating', 'done'],
|
||||
},
|
||||
{
|
||||
key: 'variant-cycling-row',
|
||||
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
|
||||
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
|
||||
},
|
||||
{
|
||||
key: 'variant-params-panel',
|
||||
ids: ['impeccable-live-params-panel'],
|
||||
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
|
||||
},
|
||||
{
|
||||
key: 'saving-confirmed-rows',
|
||||
ids: ['impeccable-live-bar'],
|
||||
states: ['saving', 'applying-variant', 'confirmed'],
|
||||
},
|
||||
{
|
||||
key: 'insert-mode-chrome',
|
||||
ids: [
|
||||
'impeccable-live-insert-line',
|
||||
'impeccable-live-insert-placeholder',
|
||||
'impeccable-live-placeholder-resize',
|
||||
'impeccable-live-insert-input',
|
||||
'impeccable-live-insert-voice',
|
||||
'impeccable-live-insert-create',
|
||||
'impeccable-live-insert-create-tooltip',
|
||||
],
|
||||
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
|
||||
},
|
||||
{
|
||||
key: 'annotation-chrome',
|
||||
ids: [
|
||||
'impeccable-live-annot',
|
||||
'impeccable-live-annot-svg',
|
||||
'impeccable-live-annot-pins',
|
||||
'impeccable-live-annot-clear',
|
||||
],
|
||||
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
|
||||
},
|
||||
{
|
||||
key: 'design-system-panel',
|
||||
ids: ['impeccable-live-design-host'],
|
||||
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
|
||||
},
|
||||
{
|
||||
key: 'toasts-and-errors',
|
||||
ids: ['impeccable-live-toast'],
|
||||
states: ['normal', 'error', 'no-variants-mounted'],
|
||||
},
|
||||
{
|
||||
key: 'css-isolation-boundary',
|
||||
ids: ['impeccable-live-root'],
|
||||
states: ['shadow-root', 'style-tags', 'hostile-css'],
|
||||
},
|
||||
]);
|
||||
|
||||
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
|
||||
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
|
||||
]);
|
||||
|
||||
export function resolveLiveUiRoot(env = globalThis) {
|
||||
const doc = env?.document;
|
||||
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|
||||
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
|
||||
if (explicit && typeof explicit.appendChild === 'function') return explicit;
|
||||
return doc?.body || null;
|
||||
}
|
||||
|
||||
export function getLiveUiElementById(id, env = globalThis) {
|
||||
const doc = env?.document;
|
||||
const root = resolveLiveUiRoot(env);
|
||||
if (!id) return null;
|
||||
if (root?.getElementById) {
|
||||
const found = root.getElementById(id);
|
||||
if (found) return found;
|
||||
}
|
||||
if (root?.querySelector) {
|
||||
const found = root.querySelector('#' + escapeCssIdent(id));
|
||||
if (found) return found;
|
||||
}
|
||||
return doc?.getElementById?.(id) || null;
|
||||
}
|
||||
|
||||
export function appendToLiveUiRoot(el, env = globalThis) {
|
||||
const root = resolveLiveUiRoot(env);
|
||||
if (!root) throw new Error('Impeccable live UI root is not available');
|
||||
root.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
|
||||
const doc = env?.document;
|
||||
const root = resolveLiveUiRoot(env);
|
||||
if (root && root !== doc?.body) {
|
||||
root.appendChild(styleEl);
|
||||
} else {
|
||||
(doc?.head || doc?.body || root).appendChild(styleEl);
|
||||
}
|
||||
return styleEl;
|
||||
}
|
||||
|
||||
export function activeElementDeep(doc = globalThis.document) {
|
||||
let active = doc?.activeElement || null;
|
||||
while (active?.shadowRoot?.activeElement) {
|
||||
active = active.shadowRoot.activeElement;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
function escapeCssIdent(value) {
|
||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||
return CSS.escape(String(value));
|
||||
}
|
||||
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Canonical inventory of the Live overlay's UI surfaces: one entry per piece of
|
||||
* chrome Live mounts on the user's page, with the element ids that make it up.
|
||||
*
|
||||
* Single source of truth, consumed by:
|
||||
* - skill/scripts/live/browser-script-parts.mjs — serializes this into
|
||||
* window.__IMPECCABLE_LIVE_UI_SURFACES__ in the /live.js prelude.
|
||||
* - skill/scripts/live-browser.js — publishes it on
|
||||
* window.__IMPECCABLE_LIVE_CHROME_CORE__ for adapters and E2E probes. That
|
||||
* file is served raw and injected as a classic <script>, so it cannot
|
||||
* import this module at runtime; it reads the injected global instead, the
|
||||
* same path live/vocabulary.mjs already takes for the command palette.
|
||||
* - the private impeccable-site repo — site/components/LiveUiGallery.astro
|
||||
* and tests/live-ui-lab.test.mjs import LIVE_UI_SURFACES at build time and
|
||||
* fail the site build when the Live UI lab has no snapshot for a surface
|
||||
* defined here. That guard only guards if it reads this list rather than a
|
||||
* copy the site keeps, so this module must stay importable from Node.
|
||||
* Renaming a key or the module is a breaking change for that build; the
|
||||
* list was briefly inlined into live-browser.js and the site had to parse
|
||||
* it back out with a regex.
|
||||
*
|
||||
* Add a surface here and both the browser bundle and the site lab follow.
|
||||
*/
|
||||
|
||||
/** Id prefix every Live chrome element carries. Mirrored by PREFIX in live-browser.js. */
|
||||
export const LIVE_UI_PREFIX = 'impeccable-live';
|
||||
|
||||
const id = (suffix) => `${LIVE_UI_PREFIX}-${suffix}`;
|
||||
|
||||
/**
|
||||
* The mount contract every Live chrome adapter (DOM, Svelte, ...) satisfies.
|
||||
* Published alongside the surfaces on __IMPECCABLE_LIVE_CHROME_CORE__.
|
||||
*/
|
||||
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze(['root', 'transport', 'state', 'actions']);
|
||||
|
||||
export const LIVE_UI_SURFACES = Object.freeze([
|
||||
{
|
||||
key: 'global-bottom-bar',
|
||||
ids: [
|
||||
id('global-bar'), id('global-bar-brand'), id('pick-toggle'), id('insert-toggle'),
|
||||
id('detect-toggle'), id('detect-badge'), id('design-toggle'), id('page-chat'),
|
||||
id('page-chat-input'), id('page-chat-voice'), id('page-chat-send'),
|
||||
],
|
||||
},
|
||||
{ key: 'pending-copy-edit-dock', ids: [id('pending-dock')] },
|
||||
{
|
||||
key: 'element-selection-chrome',
|
||||
ids: [
|
||||
id('highlight'), id('tooltip'), id('bar'), id('selection-pill'), id('input'),
|
||||
id('configure-voice'), id('configure-bar-tooltip'),
|
||||
],
|
||||
},
|
||||
{ key: 'action-picker', ids: [id('picker')] },
|
||||
{ key: 'edit-chrome', ids: [id('edit-badge')] },
|
||||
{ key: 'generating-row', ids: [id('bar'), id('shader')] },
|
||||
{ key: 'variant-cycling-row', ids: [id('bar'), id('params-panel')] },
|
||||
{ key: 'variant-params-panel', ids: [id('params-panel')] },
|
||||
{ key: 'saving-confirmed-rows', ids: [id('bar')] },
|
||||
{
|
||||
key: 'insert-mode-chrome',
|
||||
ids: [
|
||||
id('insert-line'), id('insert-placeholder'), id('placeholder-resize'), id('insert-input'),
|
||||
id('insert-voice'), id('insert-create'), id('insert-create-tooltip'),
|
||||
],
|
||||
},
|
||||
{ key: 'annotation-chrome', ids: [id('annot'), id('annot-svg'), id('annot-pins'), id('annot-clear')] },
|
||||
{ key: 'design-system-panel', ids: [id('design-host')] },
|
||||
{ key: 'toasts-and-errors', ids: [id('toast'), id('mount-error')] },
|
||||
{ key: 'css-isolation-boundary', ids: [id('root')] },
|
||||
].map((surface) => Object.freeze({ ...surface, ids: Object.freeze(surface.ids) })));
|
||||
|
||||
/** Every id any surface owns, de-duplicated, in surface order. */
|
||||
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
|
||||
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
|
||||
]);
|
||||
@@ -21,7 +21,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// All known harness directories
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.github', '.grok',
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
||||
];
|
||||
|
||||
@@ -93,15 +93,17 @@ function commandPrefixForSkillsDir(skillsDir) {
|
||||
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
|
||||
}
|
||||
|
||||
function generatePinnedSkill(command, metadata, commandPrefix) {
|
||||
function generatePinnedSkill(command, metadata, commandPrefix, isCodex) {
|
||||
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
|
||||
const hint = metadata[command]?.argumentHint || '[target]';
|
||||
const providerFrontmatter = isCodex
|
||||
? `metadata:\n argument-hint: "${hint}"`
|
||||
: `argument-hint: "${hint}"\nuser-invocable: true`;
|
||||
|
||||
return `---
|
||||
name: ${command}
|
||||
description: "${desc}"
|
||||
argument-hint: "${hint}"
|
||||
user-invocable: true
|
||||
${providerFrontmatter}
|
||||
---
|
||||
|
||||
${PIN_MARKER}
|
||||
@@ -128,7 +130,7 @@ function pin(command, projectRoot) {
|
||||
|
||||
for (const skillsDir of harnessDirs) {
|
||||
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
|
||||
const content = generatePinnedSkill(command, metadata, commandPrefix);
|
||||
const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$');
|
||||
// Check if skill already exists (and isn't a pin)
|
||||
const skillDir = join(skillsDir, command);
|
||||
if (existsSync(skillDir)) {
|
||||
|
||||
@@ -29,25 +29,50 @@
|
||||
* "materials": ["letterpress", "newsprint"], // optional, rendered as tags
|
||||
* "viewport": "one line: the first-viewport composition", // optional
|
||||
* "case": "one line: the fusion verdict, honest", // optional
|
||||
* "verdict": "competitive", // optional routing tier: "wins" |
|
||||
* // "competitive" | "declined". Declined cards
|
||||
* // render demoted after the full cards:
|
||||
* // narrow, quiet, catalog art as a labeled
|
||||
* // thumb, "Adopt anyway" instead of "Build
|
||||
* // this". Still choosable; never deleted.
|
||||
* "kept": "one line: what the direction kept from this declined world",
|
||||
* "raised": [ { "from": "challenger-x", "raise": "one line" } ],
|
||||
* // assigned card only: donations taken from
|
||||
* // declined challengers, rendered as named
|
||||
* // raise lines under the identity row
|
||||
* "risk": "one line: the honest risk", // optional
|
||||
* "body": "fallback prose when the structured fields are absent",
|
||||
* "sketch": ".impeccable/sketches/assigned.webp", // optional; may not exist
|
||||
* // yet: the page shimmer-waits and polls the
|
||||
* // slot until the file lands, so serve first
|
||||
* // and generate after
|
||||
* "sketch": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
|
||||
* // full-fidelity direction comp (the field
|
||||
* // keeps the sketch era's wire name). May not
|
||||
* // exist yet: the page shimmer-waits and
|
||||
* // polls the slot until the file lands, so
|
||||
* // serve first and generate after
|
||||
* "hero": "https://... or /abs/path.webp", // optional inspiration image;
|
||||
* // rides picture-in-picture when a sketch exists
|
||||
* "board": "https://... or /abs/path.webp" // optional secondary image
|
||||
* }, ...
|
||||
* ],
|
||||
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
|
||||
* // or { "registers": ["safer", "bolder"] } to add
|
||||
* // the register steers beside it: the answer then
|
||||
* // carries "register" and the agent re-runs
|
||||
* // concept-seed with --register <value>
|
||||
* "canon": true, // adds the "Play it straight" standing exit;
|
||||
* // direction rounds only (returns {"optionId":"canon"})
|
||||
* "canonCard": { ... }, // optional: the standing exit as a full card with the
|
||||
* // same anatomy (label, thesis, palette, sketch, ...);
|
||||
* // rendered last and visually subordinate. Without it,
|
||||
* // canon stays a quiet footer action.
|
||||
* "steer": true // adds a free-text steer field returned with any answer
|
||||
* "steer": true, // adds a free-text steer field returned with any answer
|
||||
* "followup": true // this round's pick is not terminal: the server
|
||||
* // stays open awaiting --update with the next
|
||||
* // round (detached mode only), the page shows a
|
||||
* // loading hand instead of goodbye, and the
|
||||
* // answer carries followup:true so --wait knows
|
||||
* // to keep the table. Use it when a decision has
|
||||
* // a known second half, e.g. direction first,
|
||||
* // then the execution contract.
|
||||
* }
|
||||
*
|
||||
* Options render as large cards: the sketch leads when present, with the
|
||||
@@ -79,6 +104,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { openSystemBrowser } from './lib/open-system-browser.mjs';
|
||||
|
||||
function arg(name, fallback = null) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
@@ -123,11 +149,17 @@ function printAnswer(raw) {
|
||||
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code. When your harness only reads files, or runs sandboxed, download them INTO the workspace and open the relative path; a sandboxed viewer rejects absolute paths outside it. They set the craft bar the build must reach.");
|
||||
}
|
||||
if (a.sketch) {
|
||||
console.log('CHOSEN SKETCH: the decision sketch at that path may seed one comp probe; the comp round still renders its full set, because a sketch chose the direction, not the composition.');
|
||||
console.log('CHOSEN COMP: the decision comp at that path is compositional option one. On a comp-led build the comp round adds two variations beside it; on a code-led build it returns at the finish review as the critique reference. Never regenerate it from scratch.');
|
||||
}
|
||||
if (a.optionId === 'canon') {
|
||||
console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.');
|
||||
}
|
||||
if (a.optionId === 'reroll' && a.register) {
|
||||
console.log(`REGISTER: the user steered the next hand to the ${a.register} register. Re-run concept-seed with the same key, the next --reroll round, and --register ${a.register}, then follow what it prints; the register is the user's steering, never yours to pre-select.`);
|
||||
}
|
||||
if (a.followup && a.optionId !== 'reroll') {
|
||||
console.log('FOLLOWUP OPEN: the table stays open and the page is showing a loading hand. Deliver the next round now with --update --key <key> --payload <file>, then collect it with --wait; never leave the page waiting on a round you have not sent.');
|
||||
}
|
||||
} catch { /* raw answer */ }
|
||||
}
|
||||
|
||||
@@ -143,15 +175,17 @@ if (hasFlag('schema')) {
|
||||
title: 'Choose the visual world',
|
||||
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
|
||||
options: [
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/fillmore-handbill.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', case: 'Fuses cleanly: releases map to numbered pages.', sketch: '.impeccable/sketches/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], sketch: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
|
||||
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'MY PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as today’s ballad.', risk: 'Also the direction most runs in this category land on.', sketch: '.impeccable/mocks/decision/model-pick.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', sketch: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'challenger-microfiche', label: 'Microfiche Reader', verdict: 'declined', lineage: 'library microfiche stations', palette: ['#101418', '#9fb4c0'], materials: ['film grain', 'backlit glass'], case: 'Fuses poorly: listeners do not identify with archival retrieval.', kept: 'Total environmental commitment.', hero: 'https://impeccable.style/worlds/cards/archives-microfiche-reader-hero.webp' },
|
||||
],
|
||||
reroll: true,
|
||||
reroll: { registers: ['safer', 'bolder'] },
|
||||
canon: true,
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/canon.webp' },
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', sketch: '.impeccable/mocks/decision/canon.webp' },
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no sketch (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "MY PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. The sketch slot carries the card’s full-fidelity direction comp (the field keeps its wire name for compatibility). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. followup: true keeps the table open after a pick for a second round via --update (direction first, then the execution contract); send the next payload immediately, the page is waiting on it.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -195,12 +229,16 @@ if (hasFlag('wait')) {
|
||||
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
|
||||
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
|
||||
printAnswer(collected);
|
||||
// A re-roll keeps the table open: the server stays alive awaiting --update,
|
||||
// so only the answer file is consumed. Terminal choices clean up fully.
|
||||
let isRerollAnswer = false;
|
||||
try { isRerollAnswer = JSON.parse(collected).optionId === 'reroll'; } catch { /* treat as terminal */ }
|
||||
// A re-roll or a followup-round pick keeps the table open: the server stays
|
||||
// alive awaiting --update, so only the answer file is consumed. Terminal
|
||||
// choices clean up fully.
|
||||
let keepsTableOpen = false;
|
||||
try {
|
||||
const parsedAnswer = JSON.parse(collected);
|
||||
keepsTableOpen = parsedAnswer.optionId === 'reroll' || parsedAnswer.followup === true;
|
||||
} catch { /* treat as terminal */ }
|
||||
try { fs.rmSync(answerFile(key)); } catch { /* already gone */ }
|
||||
if (!isRerollAnswer) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
|
||||
if (!keepsTableOpen) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -301,11 +339,19 @@ function loadRound(json) {
|
||||
sketchSrc: sketchSrc(option.sketch),
|
||||
});
|
||||
options = parsed.options.map(decorate);
|
||||
// The verdict routes rendering: full cards first, then the canon, then the
|
||||
// declined cards dead last in their own payload order. The reorder happens
|
||||
// here so a payload that interleaves them still renders the weighing's
|
||||
// shape, and the deck reads as a gradient of standing: contenders, the
|
||||
// familiar door, then the demoted row.
|
||||
const declined = options.filter((o) => o.verdict === 'declined');
|
||||
options = options.filter((o) => o.verdict !== 'declined');
|
||||
// The standing exit as a full card: same anatomy, reserved id, rendered
|
||||
// subordinate by the page. Without it, canon stays the quiet footer action.
|
||||
if (parsed.canonCard && typeof parsed.canonCard === 'object') {
|
||||
options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
|
||||
}
|
||||
options = [...options, ...declined];
|
||||
}
|
||||
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
|
||||
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
|
||||
@@ -321,7 +367,23 @@ function page() {
|
||||
// and material tags give a text-only direction an immediate identity that
|
||||
// no generation luck can distort.
|
||||
const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
|
||||
const hasBack = (option) => Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
|
||||
const demoted = (option) => option.verdict === 'declined';
|
||||
// Salience parity: a card's imagery weight is capped by the assigned card's.
|
||||
// When the lead card has no media at all (no image generation this round,
|
||||
// and no catalog art of its own), full-bleed catalog art beside a text-only
|
||||
// assigned card would let rendering luck outvote the weighing: users click
|
||||
// the colorful thing. Declined cards are thumb-only regardless; the verdict
|
||||
// demoted them, and a full-bleed hero would promote them right back.
|
||||
const identityRound = !(options[0] && (options[0].sketchSrc || options[0].heroSrc || options[0].boardSrc));
|
||||
// A declined card never renders a full media face, sketch included: even a
|
||||
// declared sketch would buy back the salience the verdict took away.
|
||||
const faceSketch = (option) => demoted(option) ? null : option.sketchSrc;
|
||||
const thumbOnly = (option) => !faceSketch(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
|
||||
const hasMedia = (option) => Boolean(faceSketch(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
|
||||
// The back exists to keep long facts off a card whose front is an image;
|
||||
// a card with no art has no flip chip to reach it, so it gets no back and
|
||||
// the full read lives on the front instead.
|
||||
const hasBack = (option) => hasMedia(option) && Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
|
||||
const anatomy = (option) => {
|
||||
const rows = [];
|
||||
if (option.thesis) rows.push(`<p class="thesis">${esc(option.thesis)}</p>`);
|
||||
@@ -333,10 +395,43 @@ function page() {
|
||||
idBits.push(option.materials.slice(0, 4).map((m) => `<span class="tag">${esc(m)}</span>`).join(''));
|
||||
}
|
||||
if (idBits.length) rows.push(`<div class="identity">${idBits.join('')}</div>`);
|
||||
// Donations from declined challengers render as named raise lines: the
|
||||
// assigned card arrives already raised by the hand it beat, and the raise
|
||||
// is readable, because a raise nobody can read did not happen. One raise
|
||||
// renders inline; several become a compact cycler (click advances), so a
|
||||
// generous hand cannot blow the card out of proportion.
|
||||
if (Array.isArray(option.raised) && option.raised.length) {
|
||||
const nameOf = (id) => options.find((o) => o.id === id)?.label || String(id ?? '');
|
||||
const raiseLines = option.raised.slice(0, 6).map((r) => `<p class="raise"><span class="fact-label">Raised by ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
|
||||
if (raiseLines.length > 1) {
|
||||
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter to see the next raise" aria-label="Raised by the hand; activate to see the next raise">
|
||||
<div class="raises-head"><span class="fact-label">Raised by the hand</span><span class="raises-count" data-raises-count>1/${raiseLines.length}</span></div>
|
||||
${raiseLines.join('')}
|
||||
<span class="sr-live" aria-live="polite"></span>
|
||||
</div>`);
|
||||
} else {
|
||||
rows.push(`<div class="raises">${raiseLines[0]}</div>`);
|
||||
}
|
||||
}
|
||||
// Demoted art stays reachable as a labeled thumb: the catalog world
|
||||
// explains where the direction comes from without buying it back the
|
||||
// salience the verdict took away.
|
||||
if (thumbOnly(option)) {
|
||||
rows.push(`<figure class="inspo" title="Inspiration: the world this direction draws from. Your page will not look like this image."><img src="${esc(option.heroSrc || option.boardSrc)}" alt=""><figcaption>inspired by</figcaption></figure>`);
|
||||
}
|
||||
// The front carries only what the choice needs: thesis, identity, and the
|
||||
// honest risk clamped to two lines. First viewport and the case read on
|
||||
// the card's back; once the sketch lands, the first viewport is a picture.
|
||||
rows.push(fact('Risk', option.risk, 'clamp'));
|
||||
// With no art there is no back, so the full read fills the room the
|
||||
// image would have taken.
|
||||
if (hasMedia(option)) {
|
||||
rows.push(fact('Risk', option.risk, 'clamp'));
|
||||
} else {
|
||||
rows.push(fact('First viewport', option.viewport));
|
||||
rows.push(fact('The case', option.case));
|
||||
rows.push(fact('Kept', option.kept));
|
||||
rows.push(fact('Risk', option.risk));
|
||||
}
|
||||
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
|
||||
else if (option.body && option.thesis && !hasBack(option)) rows.push(`<p class="detail more">${esc(option.body)}</p>`);
|
||||
return rows.join('\n ');
|
||||
@@ -344,6 +439,7 @@ function page() {
|
||||
const backFacts = (option) => [
|
||||
fact('First viewport', option.viewport),
|
||||
fact('The case', option.case),
|
||||
fact('Kept', option.kept),
|
||||
fact('Risk', option.risk),
|
||||
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
|
||||
].filter(Boolean).join('\n ');
|
||||
@@ -353,33 +449,40 @@ function page() {
|
||||
<figcaption>inspiration</figcaption>
|
||||
</figure>` : '';
|
||||
const details = hasBack(option) ? flipChip('Details') : '';
|
||||
if (option.sketchSrc) {
|
||||
// Thumb-only art renders inside the body via anatomy(), never as a face,
|
||||
// and a declined card's sketch slot is ignored outright.
|
||||
if (thumbOnly(option)) return '';
|
||||
if (faceSketch(option)) {
|
||||
return `<div class="media sketching" data-sketch="${esc(option.sketchSrc)}">
|
||||
<div class="shimmer"><span class="sketch-note">sketching…</span></div>
|
||||
<div class="shimmer"><span class="sketch-note">rendering…</span></div>
|
||||
<img class="sketch" alt="" hidden>
|
||||
${inspiration}
|
||||
<div class="chips">${expandChip}${details}</div>
|
||||
</div>`;
|
||||
}
|
||||
if (option.heroSrc || option.boardSrc) {
|
||||
return `<div class="media">
|
||||
// Without a sketch the catalog art is the card's face; it stays a
|
||||
// labeled reference so it never reads as the promise of the build.
|
||||
return `<div class="media" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||
<p class="media-label">inspiration</p>
|
||||
<div class="chips">${expandChip}${details}</div>
|
||||
</div>`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this';
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card${option.isCanon ? ' canon' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : demoted(option) ? '<span class="kicker declined-k">Declined</span>' : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
|
||||
${media(option)}
|
||||
<div class="body">
|
||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||
<h2>${esc(option.label)}</h2>
|
||||
${anatomy(option)}
|
||||
<button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
|
||||
<button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
|
||||
</div>
|
||||
</div>
|
||||
${hasBack(option) ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
@@ -390,7 +493,7 @@ function page() {
|
||||
<div class="body back-body">
|
||||
${option.boardSrc ? `<p class="tier">The full read · ${esc(option.label)}</p>` : ''}
|
||||
${backFacts(option)}
|
||||
<button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
|
||||
<button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
@@ -481,6 +584,10 @@ function page() {
|
||||
.nav.next { right: auto; left: 50%; top: auto; bottom: 6px; transform: translate(-50%, 0); }
|
||||
.fade-prev { top: 0; left: 0; right: 0; bottom: auto; width: auto; height: 72px; background: linear-gradient(180deg, var(--ks-lacquer), transparent); }
|
||||
.fade-next { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: 72px; background: linear-gradient(0deg, var(--ks-lacquer), transparent); }
|
||||
/* In the vertical deck the cross axis is horizontal: flex-start would
|
||||
shrink a declined card to content WIDTH, not height, so it stretches
|
||||
like every other card and its height is already its own. */
|
||||
.grid > .card.declined { align-self: stretch; }
|
||||
}
|
||||
.card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
|
||||
.card:hover { transform: rotate(0deg) translateY(-4px); }
|
||||
@@ -547,6 +654,17 @@ function page() {
|
||||
.pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 3px 0 4px; background: oklch(7% 0.006 95 / 0.72); backdrop-filter: blur(3px); }
|
||||
.pip:hover { left: 0; bottom: 0; width: 100%; height: 100%; border-radius: 0; z-index: 3; }
|
||||
.sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||
/* Catalog art standing in for a sketchless card is a reference, and says so
|
||||
on its face; the same pill later carries "artwork unavailable". */
|
||||
.media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); padding: 3px 8px 4px; background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 4px; backdrop-filter: blur(3px); }
|
||||
/* Art that never arrives collapses to the card's own palette (painted
|
||||
inline from its swatches) instead of sitting as a dark void wearing a
|
||||
zoom cursor; the scrim keeps the label legible over saturated fields,
|
||||
passes clicks through, and the flip chips stay above it. A card with no
|
||||
palette falls back to the quiet graphite field. */
|
||||
.media.unavailable { background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
|
||||
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
|
||||
.media.unavailable .chips { z-index: 2; }
|
||||
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
||||
the real sketch whenever it lands. */
|
||||
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
||||
@@ -558,6 +676,42 @@ function page() {
|
||||
/* The generic .media img display:block would defeat [hidden] and float an
|
||||
empty block over the shimmer; an unloaded sketch must truly not render. */
|
||||
.media img[hidden] { display: none; }
|
||||
/* Declined challengers: the weighing demoted them, so the card is narrower
|
||||
and quieter, its catalog art rides as a labeled thumb in the body, and
|
||||
the action reads "Adopt anyway". Adoptable, never deleted: the demoted
|
||||
row is the hand's proof of judgment. */
|
||||
/* Narrow AND short: without align-self the stretch default drags a thin
|
||||
declined card to the tallest contender's height, a strange stilt of a
|
||||
card beside the full hand. */
|
||||
.grid > .card.declined { flex: 0 0 clamp(15rem, 21vw, 21rem); align-self: flex-start; }
|
||||
.card.declined .face { background: var(--ks-graphite); }
|
||||
.card.declined:hover .face { border-color: var(--ks-text-faint); }
|
||||
.card.declined h2 { font-size: 1rem; color: var(--ks-text); }
|
||||
.kicker.declined-k { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); }
|
||||
.card.declined button.choose { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font-size: .85rem; padding: 8px 22px; }
|
||||
.card.declined button.choose:hover { background: var(--ks-graphite-2); border-color: var(--ks-text-muted); }
|
||||
/* Thumb-scale inspiration: present, labeled, zoomable, and incapable of
|
||||
outshouting a text-only assigned card. */
|
||||
.inspo { position: relative; flex: none; margin: 2px 0; width: 104px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; cursor: zoom-in; background: var(--ks-lacquer); }
|
||||
.inspo img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.inspo figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .48rem; letter-spacing: .16em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 2px 0 3px; background: oklch(7% 0.006 95 / 0.72); }
|
||||
/* Raises: the donations the assigned direction took from the hand it beat,
|
||||
each named for its donor. Patina, not kinpaku: a raise is provenance. */
|
||||
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; }
|
||||
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; border-left: 2px solid var(--ks-patina); padding-left: 8px; }
|
||||
.raise .fact-label { color: var(--ks-patina); }
|
||||
/* Several raises cycle instead of stacking: one visible at a time, a
|
||||
counter for the rest, the whole block advances on click. */
|
||||
.raises-cycle { cursor: pointer; border-radius: 6px; }
|
||||
.raises-cycle .raise { display: none; border-left: none; padding-left: 0; }
|
||||
.raises-cycle .raise.active { display: block; }
|
||||
.raises-cycle { border-left: 2px solid var(--ks-patina); padding-left: 8px; }
|
||||
.raises-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.raises-head .fact-label { color: var(--ks-patina); }
|
||||
.raises-count { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .14em; color: var(--ks-text-faint); }
|
||||
.raises-count::after { content: " \\203A"; }
|
||||
.raises-cycle:hover .raises-count { color: var(--ks-patina); }
|
||||
.sr-live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
/* The standing exit as a card: present with full anatomy, never dressed as a
|
||||
contender. Graphite instead of kinpaku, and it never takes the lead ring. */
|
||||
.card.canon .face { border-color: var(--ks-rule); background: var(--ks-graphite); }
|
||||
@@ -570,9 +724,14 @@ function page() {
|
||||
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
||||
#steer { flex: 1; min-width: 16rem; background: var(--ks-lacquer-raised); color: var(--ks-text); border: 1px solid var(--ks-rule); border-radius: 7px; padding: .6rem .85rem; font: inherit; }
|
||||
#steer:focus { outline: none; border-color: var(--ks-patina); }
|
||||
#reroll { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
#reroll svg { width: 15px; height: 15px; }
|
||||
.reroll-btn { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
.reroll-btn:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
.reroll-btn svg { width: 15px; height: 15px; }
|
||||
.reroll-btn[disabled] { opacity: .4; cursor: default; }
|
||||
/* The register steers read quieter than the plain roll: they are exits from
|
||||
the current register, not the round's main verbs. */
|
||||
#reroll-safer, #reroll-bolder { color: var(--ks-text-muted); min-height: 38px; }
|
||||
#reroll-safer:hover, #reroll-bolder:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
|
||||
/* The quiet exit: always available, never argued with, visually subordinate
|
||||
to the dealt cards and the re-roll so it reads as the user's own door,
|
||||
not a recommendation. */
|
||||
@@ -617,16 +776,33 @@ function page() {
|
||||
</main>
|
||||
<footer>
|
||||
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
|
||||
${payload.reroll ? '<button id="reroll"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg><span>Re-roll</span></button>' : ''}
|
||||
${(() => {
|
||||
if (!payload.reroll) return '';
|
||||
const die = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
|
||||
const registers = Array.isArray(payload.reroll.registers) ? payload.reroll.registers.filter((r) => r === 'safer' || r === 'bolder') : [];
|
||||
// The registers are the user's steering wheel on the familiar-to-bold
|
||||
// axis; the plain re-roll sits between them so the spatial order matches
|
||||
// the axis it names.
|
||||
const safer = registers.includes('safer') ? '<button class="reroll-btn" id="reroll-safer" title="Deal the familiar register: conventional grounded directions plus the category standard against named competitors"><span>← Safer hand</span></button>' : '';
|
||||
const bolder = registers.includes('bolder') ? '<button class="reroll-btn" id="reroll-bolder" title="Deal foreign forms only, at full commitment"><span>Bolder hand →</span></button>' : '';
|
||||
return `${safer}<button class="reroll-btn" id="reroll">${die}<span>Re-roll</span></button>${bolder}`;
|
||||
})()}
|
||||
${payload.canon && !payload.canonCard ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
|
||||
</footer>
|
||||
<script>
|
||||
const steer = () => document.getElementById('steer')?.value || '';
|
||||
// A followup round's pick keeps the tab: the next round arrives via
|
||||
// --update, so the page shows the loading hand instead of goodbye. Detached
|
||||
// mode only, and the page must agree with the server: a blocking server
|
||||
// exits on any pick and has no update channel, so a followup payload there
|
||||
// still gets the goodbye screen, never a loading hand nothing will resolve.
|
||||
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
|
||||
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
|
||||
beat();
|
||||
setInterval(beat, 5000);
|
||||
async function answer(optionId) {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
|
||||
if (FOLLOWUP) { await awaitNextRound(); return; }
|
||||
document.body.innerHTML = '<div class="done"><svg viewBox="0 0 24 24" width="38" height="38" fill="oklch(84% 0.19 80.46)" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>Choice recorded. The agent is resuming; you can close this tab.</div>';
|
||||
}
|
||||
document.querySelectorAll('button.choose').forEach(b => b.addEventListener('click', () => answer(b.dataset.id)));
|
||||
@@ -635,6 +811,25 @@ function page() {
|
||||
b.closest('.card').classList.toggle('flipped');
|
||||
}));
|
||||
|
||||
// Raise cycler: click (or Enter) advances to the next donation.
|
||||
document.querySelectorAll('.raises-cycle').forEach(cycle => {
|
||||
const raises = [...cycle.querySelectorAll('.raise')];
|
||||
const count = cycle.querySelector('[data-raises-count]');
|
||||
let at = 0;
|
||||
const live = cycle.querySelector('.sr-live');
|
||||
const show = (announce) => {
|
||||
raises.forEach((raise, i) => raise.classList.toggle('active', i === at));
|
||||
if (count) count.textContent = (at + 1) + '/' + raises.length;
|
||||
// Screen readers hear the raise they just advanced to; the initial
|
||||
// render stays quiet so page load does not narrate every card.
|
||||
if (announce && live) live.textContent = 'Raise ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');
|
||||
};
|
||||
show(false);
|
||||
const advance = (e) => { e.stopPropagation(); at = (at + 1) % raises.length; show(true); };
|
||||
cycle.addEventListener('click', advance);
|
||||
cycle.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); advance(e); } });
|
||||
});
|
||||
|
||||
// Deal from the stack: cards begin piled at the grid's center, blurred,
|
||||
// then travel to their seats with a stagger.
|
||||
const cards = [...document.querySelectorAll('.card')];
|
||||
@@ -682,7 +877,7 @@ function page() {
|
||||
const note = m.querySelector('.sketch-note');
|
||||
const started = Date.now();
|
||||
// A live elapsed count is the difference between "working" and "frozen".
|
||||
const tick = setInterval(() => { if (note) note.textContent = 'sketching · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
|
||||
const tick = setInterval(() => { if (note) note.textContent = 'rendering · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
|
||||
const settle = () => { clearInterval(tick); m.classList.remove('sketching', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
|
||||
const standIn = () => {
|
||||
const pip = m.querySelector('.pip img');
|
||||
@@ -693,7 +888,7 @@ function page() {
|
||||
clearInterval(tick);
|
||||
const label = document.createElement('p');
|
||||
label.className = 'stand-in-label';
|
||||
label.textContent = 'inspiration · sketch pending';
|
||||
label.textContent = 'inspiration · comp pending';
|
||||
m.appendChild(label);
|
||||
};
|
||||
const tryLoad = () => {
|
||||
@@ -709,8 +904,38 @@ function page() {
|
||||
tryLoad();
|
||||
});
|
||||
|
||||
// Inspiration PIP opens the full catalog card in the lightbox.
|
||||
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
|
||||
// A declared image that never loads (missing catalog asset, offline shell)
|
||||
// must not sit as a dark void: the slot collapses to the card's own
|
||||
// palette, labeled honestly, and the card competes on its facts. Sketch
|
||||
// slots are excluded; their polling owns the wait.
|
||||
const artFailed = (img) => {
|
||||
const m = img.closest('.media');
|
||||
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
|
||||
m.classList.add('unavailable');
|
||||
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
|
||||
if (colors.length) m.style.background = 'linear-gradient(135deg, ' + colors.map((c, i) => c + ' ' + Math.round(i * 100 / colors.length) + '% ' + Math.round((i + 1) * 100 / colors.length) + '%').join(', ') + ')';
|
||||
m.querySelector('.media-label')?.remove();
|
||||
m.querySelector('.chip.expand')?.remove();
|
||||
m.removeAttribute('title');
|
||||
img.remove();
|
||||
const label = document.createElement('p');
|
||||
label.className = 'media-label';
|
||||
label.textContent = 'artwork unavailable';
|
||||
m.appendChild(label);
|
||||
};
|
||||
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
|
||||
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
||||
else img.addEventListener('error', () => artFailed(img), { once: true });
|
||||
});
|
||||
// A broken inspiration PIP or thumb just leaves; nothing depends on it.
|
||||
document.querySelectorAll('.pip img, .inspo img').forEach(img => {
|
||||
const gone = () => img.closest('.pip, .inspo')?.remove();
|
||||
if (img.complete && img.naturalWidth === 0) gone();
|
||||
else img.addEventListener('error', gone, { once: true });
|
||||
});
|
||||
|
||||
// Inspiration PIP or body thumb opens the full catalog card in the lightbox.
|
||||
document.querySelectorAll('.pip, .inspo').forEach(p => p.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const img = p.querySelector('img');
|
||||
if (!img) return;
|
||||
@@ -757,7 +982,7 @@ function page() {
|
||||
const ambient = document.getElementById('ambient');
|
||||
document.querySelectorAll('.card').forEach(card => {
|
||||
card.addEventListener('mouseenter', () => {
|
||||
const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img');
|
||||
const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img') || card.querySelector('.face.front .inspo img');
|
||||
if (!art || !art.getAttribute('src')) return;
|
||||
ambient.style.backgroundImage = 'url("' + art.getAttribute('src') + '")'; ambient.style.opacity = '1';
|
||||
});
|
||||
@@ -805,8 +1030,11 @@ function page() {
|
||||
lightbox.addEventListener('click', closeLightbox);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
||||
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
|
||||
document.getElementById('reroll')?.addEventListener('click', async () => {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
|
||||
const dealAgain = async (register) => {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
|
||||
await awaitNextRound();
|
||||
};
|
||||
async function awaitNextRound() {
|
||||
const grid = document.querySelector('.grid');
|
||||
const cardsNow = [...grid.querySelectorAll('.card')];
|
||||
const g = grid.getBoundingClientRect();
|
||||
@@ -823,14 +1051,17 @@ function page() {
|
||||
}
|
||||
const cardHeight = cardsNow[0] ? cardsNow[0].getBoundingClientRect().height : 0;
|
||||
grid.innerHTML = cardsNow.map(() => '<article class="card skeleton"' + (cardHeight ? ' style="height:' + cardHeight + 'px"' : '') + '><div class="card-inner"><div class="face front"><div class="media"><div class="shimmer"></div></div><div class="body"><div class="line tier w40"></div><div class="line title w70"></div><div class="line w90"></div><div class="line w80"></div><div class="line w60"></div><div class="line button"></div></div></div></div></article>').join('');
|
||||
document.getElementById('reroll')?.setAttribute('disabled', '');
|
||||
document.querySelectorAll('.reroll-btn').forEach(b => b.setAttribute('disabled', ''));
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const status = await (await fetch('/next-status')).json();
|
||||
if (status.ready) { clearInterval(poll); location.reload(); }
|
||||
} catch { /* server briefly busy */ }
|
||||
}, 1200);
|
||||
});
|
||||
}
|
||||
document.getElementById('reroll')?.addEventListener('click', () => dealAgain());
|
||||
document.getElementById('reroll-safer')?.addEventListener('click', () => dealAgain('safer'));
|
||||
document.getElementById('reroll-bolder')?.addEventListener('click', () => dealAgain('bolder'));
|
||||
</script>`;
|
||||
}
|
||||
|
||||
@@ -887,22 +1118,29 @@ const server = http.createServer((req, res) => {
|
||||
let parsed = {};
|
||||
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
|
||||
const chosen = options.find((o) => o.id === parsed.optionId);
|
||||
const isReroll = parsed.optionId === 'reroll';
|
||||
// A followup round's pick is not terminal: the table stays open for the
|
||||
// next round (--update), exactly like a re-roll. Detached mode only;
|
||||
// the blocking mode has no update channel, so its picks stay terminal.
|
||||
const followupOpen = Boolean(detachedKey) && payload.followup === true && !isReroll;
|
||||
const answer = JSON.stringify({
|
||||
optionId: parsed.optionId ?? null,
|
||||
steer: parsed.steer ?? '',
|
||||
...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}),
|
||||
...(followupOpen ? { followup: true } : {}),
|
||||
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||
...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
|
||||
});
|
||||
const isReroll = parsed.optionId === 'reroll';
|
||||
if (detachedKey) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
|
||||
} else {
|
||||
printAnswer(answer);
|
||||
}
|
||||
// A re-roll in detached mode keeps the table open: the client shows a
|
||||
// loading hand and reloads when --update delivers the next round.
|
||||
if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150);
|
||||
// A re-roll or followup pick in detached mode keeps the table open: the
|
||||
// client shows a loading hand and reloads when --update delivers the
|
||||
// next round.
|
||||
if (!((isReroll || followupOpen) && detachedKey)) setTimeout(() => process.exit(0), 150);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -920,8 +1158,7 @@ server.listen(portArg, '127.0.0.1', () => {
|
||||
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
|
||||
}
|
||||
if (!hasFlag('no-open')) {
|
||||
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
||||
try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ }
|
||||
openSystemBrowser(url);
|
||||
}
|
||||
if (timeoutSec > 0) {
|
||||
setTimeout(() => {
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"",
|
||||
"command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 5,
|
||||
"statusMessage": "Checking UI changes"
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"",
|
||||
"command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Design deep pass"
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ Your job is production cleanup, not new art direction. Work only from the approv
|
||||
|
||||
Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster.
|
||||
|
||||
## Decision Sketches
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one sketch: one card, one file, written to the card's declared `sketch` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a sketch is reported back, not padded from imagination. Render through the parent's shared frame, including its aspect: the requested surface's first viewport as a flat, matte design sketch in the card's own palette and type character, deliberately unfinished, no photorealism, no gloss; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. The frame is shared across siblings so no sketch looks more finished than another; a finish gap breaks the comparison. The only legible text is the product's real name and one real headline; greek every other text region into indistinct lines, because an invented spec, price, or date in a sketch is a claim PRODUCT.md never made. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a sketch run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
|
||||
|
||||
## Input Contract
|
||||
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); desktop and mobile screenshot paths captured by the parent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and the approved comp path; and the skill's `reference/craft-floor.md` path. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, which live in `.impeccable/review/` (on the web, `desktop.png` and `mobile.png`; on 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, and `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and, on a comp-led build, the approved comp path (a code-led build has no approved comp; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing in this file 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 also carries 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 and judge every check in the platform's own conventions, the screenshots are device captures rather than browser viewports, and your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
|
||||
|
||||
## Checks, in order
|
||||
|
||||
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
|
||||
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <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. 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, and that is a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and they 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, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. A critique-reference comp, when one arrived on such a build, is provocation rather than spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is the question of what the image dared that the build did not, and the 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. In every material_fixes list, 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 and that is a material fix ahead of any craft point. Then, for each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport.
|
||||
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
|
||||
@@ -38,4 +38,4 @@ Return the disposition line first, then exactly five sections: `persistence` (pa
|
||||
|
||||
## Verdict Pass
|
||||
|
||||
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
|
||||
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent recaptures over the same screenshot files you read in the review round, so re-read those exact paths for this round; a round-stamped filename you invent points at nothing. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"hooks": {
|
||||
"preToolUse": [
|
||||
{
|
||||
"command": "[ ! -f \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\" ] || ! node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || node \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\"",
|
||||
"command": "[ ! -f \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\" ] || ! node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || node \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
|
||||
@@ -10,11 +10,11 @@ This skill gives you the tools and permission to create design that earns to be
|
||||
Core principles:
|
||||
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
|
||||
- Dream big and bold. Distinct, beautiful, outstanding and highly inspiring work.
|
||||
- Verify in bounded passes, not a loop, and the ceiling covers the whole cycle: screenshots, defect scans, micro-edits, and rebuilds alike. Build fully, inspect once with a batched round (desktop and mobile together), fix everything it shows in one batch, confirm with at most one more round, and stop polishing. Open-ended self-QA burns the user's money doing worse what the finish handoffs do better.
|
||||
- Verify in bounded passes, not a loop, and the ceiling covers the whole cycle: screenshots, defect scans, micro-edits, and rebuilds alike. Build fully, inspect once with a batched round (desktop and mobile together on the web; the shipped device classes on a native platform), fix everything it shows in one batch, confirm with at most one more round, and stop polishing. Open-ended self-QA burns the user's money doing worse what the finish handoffs do better.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Run `node .cursor/skills/impeccable/scripts/context.mjs` once per session (if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs`; keep cwd at the user's project). 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.
|
||||
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 .cursor/skills/impeccable/scripts/...` command in this skill and its references, and `.cursor/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.
|
||||
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.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user