mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 07:06:45 +03:00
Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae56d719af | ||
|
|
83e8b4645c | ||
|
|
4f08ff3bfc | ||
|
|
9d723f39df | ||
|
|
d6c2442dbe | ||
|
|
816ffe92d0 | ||
|
|
c0e7f2d778 | ||
|
|
65de2d294b | ||
|
|
07663f5fbd | ||
|
|
c489335799 | ||
|
|
ddd23b1807 | ||
|
|
710aa57637 | ||
|
|
9b404edff5 | ||
|
|
504b8f2a22 | ||
|
|
628509b948 | ||
|
|
121602079c | ||
|
|
3b96bd5090 | ||
|
|
fde4a3ee71 | ||
|
|
7d907bbb14 | ||
|
|
ebc63f071a | ||
|
|
d4e1b0902f | ||
|
|
0e5c6cbe17 | ||
|
|
bd25359748 | ||
|
|
9e8b9bc389 | ||
|
|
9632b33f6c | ||
|
|
9b055c82d7 | ||
|
|
74ca4dd7a8 | ||
|
|
76b9aaf021 | ||
|
|
1d5e05785b | ||
|
|
c8b5395e79 | ||
|
|
ea90b23bc8 | ||
|
|
d14711ae3d | ||
|
|
64dd60a78a | ||
|
|
6129744410 | ||
|
|
e2421aff43 | ||
|
|
6b7f62979b | ||
|
|
ac4c3200db | ||
|
|
89368a2430 | ||
|
|
e36833ce21 | ||
|
|
d6ea967ea1 | ||
|
|
6c837bd7d4 | ||
|
|
a528992b98 | ||
|
|
f66eace20d | ||
|
|
3a26dcb809 | ||
|
|
37be3fa36b | ||
|
|
fbf3ee01b1 | ||
|
|
68f3d18568 | ||
|
|
53a6947653 | ||
|
|
3b2566d4d1 | ||
|
|
f1b7111503 | ||
|
|
cb305fdca1 | ||
|
|
e91c273361 | ||
|
|
e867487d55 | ||
|
|
248a4a699a | ||
|
|
62e90a257f | ||
|
|
ae388ac58f | ||
|
|
ac0416b655 | ||
|
|
c0b9b6f95a | ||
|
|
dc4e4a4bd6 | ||
|
|
357f358050 | ||
|
|
d4aacaccfd | ||
|
|
def69e157b | ||
|
|
251135e190 | ||
|
|
aee5ddd10c | ||
|
|
8e62ab47ba | ||
|
|
5f7b001cbe | ||
|
|
d23fa1c882 | ||
|
|
520a55547e | ||
|
|
82234515e0 | ||
|
|
94e957d7fc | ||
|
|
92c857a9ef | ||
|
|
2ab054d1f4 | ||
|
|
7fa695093e | ||
|
|
63fb8a56f9 | ||
|
|
0dd4f90af6 | ||
|
|
ab9a29728b | ||
|
|
29e5b1494c | ||
|
|
181212cb2d | ||
|
|
c38ad8fb8b | ||
|
|
19786e7a22 | ||
|
|
1cbee026c3 | ||
|
|
045865918a | ||
|
|
5c8652b019 | ||
|
|
490dcfd678 | ||
|
|
4596f3183c | ||
|
|
ddf4526fb5 | ||
|
|
628aac5a40 | ||
|
|
477484aaee | ||
|
|
5d10bc842c | ||
|
|
fc05472a20 | ||
|
|
f254e7685d | ||
|
|
dbff0880e6 | ||
|
|
d65a08b064 | ||
|
|
c70bcbf6b4 | ||
|
|
aee6ce9352 | ||
|
|
5ce4a5c6b5 |
@@ -9,11 +9,11 @@ This skill gives you the tools and permission to create design that earns to be
|
|||||||
Core principles:
|
Core principles:
|
||||||
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
|
- 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.
|
- 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
|
## 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.
|
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.
|
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.
|
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 `comp` 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 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. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||||
|
|
||||||
## Input Contract
|
## 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.
|
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.
|
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.
|
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.
|
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.
|
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
|
## 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
|
## 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.
|
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. 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.
|
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.
|
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.
|
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.
|
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
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
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
|
## Verify
|
||||||
|
|
||||||
- The focal motion is specific to the selected world and surface.
|
- The focal motion is specific to the selected world and surface.
|
||||||
- Every supporting animation explains feedback, state, or relationship.
|
- Every supporting animation explains feedback, state, or relationship.
|
||||||
- Interruption and repeated use behave correctly.
|
- Interruption and repeated use behave correctly.
|
||||||
- Desktop, mobile, and keyboard paths remain usable.
|
- 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.
|
- Expensive effects stay smooth on the target device.
|
||||||
- Removing an animation would lose meaning or authored character, not merely decoration.
|
- Removing an animation would lose meaning or authored character, not merely decoration.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
> **Additional context needed**: which section is the target, and what must stay untouched.
|
> **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.
|
"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
|
## Scope is sovereign
|
||||||
|
|
||||||
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, stop and STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. before expanding it, naming the exact addition and the job it would do.
|
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, do not expand it on your own. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Name the exact addition and the job it would do.
|
||||||
|
|
||||||
## Why it reads flat
|
## Why it reads flat
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ Resolve one stable target, run two independent assessments, synthesize a design
|
|||||||
- Viewable targets require browser inspection when available.
|
- Viewable targets require browser inspection when available.
|
||||||
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
|
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
|
||||||
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
|
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
|
||||||
|
- The question is the LAST thing in the response. Write the entire report out first, then ask; nothing follows the question. Prose emitted after a structured question is withheld until the user answers it, so a report written after the question reads as if the critique never ran.
|
||||||
|
- A run that ends with neither the targeted questions nor a literal `Questions skipped: <reason>` line is an incomplete run. The report is not the finish; the close is.
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
@@ -192,6 +194,14 @@ Codex Run Notes are final-chat only. Do not include this section in the persiste
|
|||||||
- Prioritize ruthlessly. If everything is important, nothing is.
|
- Prioritize ruthlessly. If everything is important, nothing is.
|
||||||
- Don't soften criticism. Developers need honest feedback to ship great design.
|
- Don't soften criticism. Developers need honest feedback to ship great design.
|
||||||
|
|
||||||
|
### Deliver the Report
|
||||||
|
|
||||||
|
Write the full report into the chat response now, before any persistence work. This is the deliverable; everything below it is bookkeeping.
|
||||||
|
|
||||||
|
Do this first because the alternative is the most common way this command fails: the report gets composed once, straight into the persistence heredoc, and the run ends with a perfect archive nobody has read. Composing it into a file is not delivering it. If the report exists only in `.impeccable/critique/`, the run produced nothing.
|
||||||
|
|
||||||
|
Persistence is not the end of the run. After it, the response continues with the trend line and the close.
|
||||||
|
|
||||||
### Persist the Snapshot
|
### Persist the Snapshot
|
||||||
|
|
||||||
Once the report above is finalized, write it to `.impeccable/critique/` so the user can refer back, and so `$impeccable polish` can pick up the priority issues without a copy-paste.
|
Once the report above is finalized, write it to `.impeccable/critique/` so the user can refer back, and so `$impeccable polish` can pick up the priority issues without a copy-paste.
|
||||||
@@ -200,6 +210,8 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
|||||||
|
|
||||||
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, design-specificity verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
|
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, design-specificity verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
|
||||||
|
|
||||||
|
This is a copy of the report you already delivered above, for later commands to read. It is not delivery. If you find yourself composing the report for the first time inside this heredoc, you have skipped Deliver the Report; go back and send it.
|
||||||
|
|
||||||
Codex: exclude Run Notes from the temp body file; Run Notes are final-chat only because persistence, trend read, and temp cleanup happen after the snapshot write.
|
Codex: exclude Run Notes from the temp body file; Run Notes are final-chat only because persistence, trend read, and temp cleanup happen after the snapshot write.
|
||||||
|
|
||||||
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
|
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
|
||||||
@@ -226,12 +238,16 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
|||||||
|
|
||||||
If this is the first run for the slug, the trend is just one score; say so: "First run for this target, no trend yet."
|
If this is the first run for the slug, the trend is just one score; say so: "First run for this target, no trend yet."
|
||||||
|
|
||||||
|
6. **Close the run.** Go to Ask the User below and emit the questions, or the `Questions skipped: <reason>` line when the count allows it. The run is not complete until you do. Persistence is bookkeeping and cleanup is not an ending; stopping here leaves the user with a report and no way forward, and leaves `$impeccable polish` with no priorities to inherit.
|
||||||
|
|
||||||
This is fire-and-forget. Do not show the user the helper's JSON output; only the human-readable trend line and the written path. Failures here should not block the rest of the flow; print the error and move on.
|
This is fire-and-forget. Do not show the user the helper's JSON output; only the human-readable trend line and the written path. Failures here should not block the rest of the flow; print the error and move on.
|
||||||
|
|
||||||
### Ask the User
|
### Ask the User
|
||||||
|
|
||||||
**After presenting findings**, use targeted questions based on what was actually found. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. These answers will shape the action plan.
|
**After presenting findings**, use targeted questions based on what was actually found. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. These answers will shape the action plan.
|
||||||
|
|
||||||
|
Ask in the same message that carries the report, with the report written out first and the question last. Do not split the two across turns: a turn that ends on the report is a turn that ends, and the questions never arrive. Order within the message is what matters, because prose emitted after a structured question is withheld until the user answers.
|
||||||
|
|
||||||
Ask questions along these lines (adapt to the specific findings; do NOT ask generic questions):
|
Ask questions along these lines (adapt to the specific findings; do NOT ask generic questions):
|
||||||
|
|
||||||
1. **Priority direction**: Based on the issues found, ask which category matters most to the user right now. For example: "I found problems with visual hierarchy, color usage, and information overload. Which area should we tackle first?" Offer the top 2-3 issue categories as options.
|
1. **Priority direction**: Based on the issues found, ask which category matters most to the user right now. For example: "I found problems with visual hierarchy, color usage, and information overload. Which area should we tackle first?" Offer the top 2-3 issue categories as options.
|
||||||
@@ -246,9 +262,9 @@ Ask questions along these lines (adapt to the specific findings; do NOT ask gene
|
|||||||
- Every question must reference specific findings from the report. Never ask generic "who is your audience?" questions.
|
- Every question must reference specific findings from the report. Never ask generic "who is your audience?" questions.
|
||||||
- Keep it to 2-4 questions maximum. Respect the user's time.
|
- Keep it to 2-4 questions maximum. Respect the user's time.
|
||||||
- Offer concrete options, not open-ended prompts.
|
- Offer concrete options, not open-ended prompts.
|
||||||
- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Recommended Actions.
|
- Skipping is allowed only when the report listed **fewer than 3 Priority Issues**. Count them; do not judge the findings "straightforward" by feel. At 3 or more, the questions are required.
|
||||||
|
|
||||||
Codex final-question gate: The user-visible response must either include the targeted questions or explicitly say `Questions skipped: <reason>` because the findings were straightforward. Each question must include 2-3 concrete answer options tied to the actual critique findings. Do not end with only open-ended questions.
|
**Final-question gate.** The user-visible response must either include the targeted questions or carry the literal line `Questions skipped: <reason>` naming the count that permitted the skip. Each question must include 2-3 concrete answer options tied to the actual critique findings. Do not end with only open-ended questions, and do not end with neither: stopping after the report, having asked nothing and printed no skip line, is the most common way this command fails.
|
||||||
|
|
||||||
### Recommended Actions
|
### Recommended Actions
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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 `comp` 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 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. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||||
|
|
||||||
## Input Contract
|
## Input Contract
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
|
|||||||
|
|
||||||
## Input Contract
|
## 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
|
## 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.
|
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. 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.
|
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.
|
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.
|
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.
|
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
|
## 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.
|
||||||
@@ -21,7 +21,7 @@ Analyze what makes the design feel complex or cluttered:
|
|||||||
- What can be removed, hidden, or combined?
|
- What can be removed, hidden, or combined?
|
||||||
- What's the 20% that delivers 80% of value?
|
- What's the 20% that delivers 80% of value?
|
||||||
|
|
||||||
If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
|
If any of these are unclear from the codebase, do not guess. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
|
||||||
|
|
||||||
**CRITICAL**: Simplicity is not about removing features. It's about removing obstacles between users and their goals. Every element should justify its existence.
|
**CRITICAL**: Simplicity is not about removing features. It's about removing obstacles between users and their goals. Every element should justify its existence.
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ The same restraint applies to `workspace-context-inherited`. Inheritance is a de
|
|||||||
|
|
||||||
- `workspace-platform-native-evidence` is the finding that matters most here: a workspace carrying native build files while inheriting a root record that resolves to web gets web guidance for its whole life and never loads [ios.md](ios.md) or [android.md](android.md). The repair is a child PRODUCT.md in that workspace, because one inherited record cannot hold two platforms.
|
- `workspace-platform-native-evidence` is the finding that matters most here: a workspace carrying native build files while inheriting a root record that resolves to web gets web guidance for its whole life and never loads [ios.md](ios.md) or [android.md](android.md). The repair is a child PRODUCT.md in that workspace, because one inherited record cannot hold two platforms.
|
||||||
- `config-project-roots-match-nothing` means every `projectRoots` glob missed, so the repo root is silently standing in as the active project. A renamed workspace directory is the usual cause. Report the patterns and ask which directories they should name.
|
- `config-project-roots-match-nothing` means every `projectRoots` glob missed, so the repo root is silently standing in as the active project. A renamed workspace directory is the usual cause. Report the patterns and ask which directories they should name.
|
||||||
|
- `config-invalid-build-path` and `config-build-path-unset` both concern one key, `buildPath` in `.impeccable/config.json` (or the gitignored `.impeccable/config.local.json`, which wins for that developer). It holds `comp` or `code` and sets whether new surfaces are built from a generated comp or straight in code. An unread value does not fall back to the opposite path, so a project meaning `code` has been building comp-led; report the exact value. The unset finding fires only where a project has done direction work and never recorded a preference, and the offer belongs in it only when image generation exists in your tool surface. Without image generation there is nothing to choose and nothing to say.
|
||||||
- Use the `workspaces` table to show the user which apps carry their own context, which inherit, and which have none, before proposing any change.
|
- Use the `workspaces` table to show the user which apps carry their own context, which inherit, and which have none, before proposing any change.
|
||||||
|
|
||||||
## Opting out of the boot check
|
## Opting out of the boot check
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Omit irrelevant sections rather than filling them with invented rules. Put respo
|
|||||||
- An existing `DESIGN.md` is stale (the design has drifted).
|
- An existing `DESIGN.md` is stale (the design has drifted).
|
||||||
- Before a large redesign, to capture the current state as a reference.
|
- Before a large redesign, to capture the current state as a reference.
|
||||||
|
|
||||||
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file and STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. whether to refresh, overwrite, or merge.
|
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file first. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. The choice is refresh, overwrite, or merge.
|
||||||
|
|
||||||
## Two paths
|
## Two paths
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Identify reusable patterns, components, and design tokens, then extract and cons
|
|||||||
|
|
||||||
Find the design system, component library, or shared UI directory. Understand its structure: component organization, naming conventions, design token structure, import/export conventions.
|
Find the design system, component library, or shared UI directory. Understand its structure: component organization, naming conventions, design token structure, import/export conventions.
|
||||||
|
|
||||||
**CRITICAL**: If no design system exists, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. before creating one. Understand the preferred location and structure first.
|
**CRITICAL**: If no design system exists, do not create one yet. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Understand the preferred location and structure first.
|
||||||
|
|
||||||
## Step 2: Identify Patterns
|
## Step 2: Identify Patterns
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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:
|
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.
|
- 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` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
- 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.
|
- 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.
|
- 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.
|
- 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"
|
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
|
```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:
|
Example whole-rule font exception:
|
||||||
|
|||||||
@@ -107,9 +107,11 @@ When the platform you just recorded is `ios`, `android`, or `adaptive`, load [io
|
|||||||
|
|
||||||
Before loading new-work or resuming shape/build, verify that PRODUCT.md exists at the resolved path and contains the confirmed product record. If the file is absent, init is incomplete. Do not substitute interview notes, a planning packet, or later design prose for the file.
|
Before loading new-work or resuming shape/build, verify that PRODUCT.md exists at the resolved path and contains the confirmed product record. If the file is absent, init is incomplete. Do not substitute interview notes, a planning packet, or later design prose for the file.
|
||||||
|
|
||||||
## Step 5: Configure live mode when useful
|
## Step 5: Record workflow defaults
|
||||||
|
|
||||||
Skip native or non-runnable projects and leave existing config untouched. Otherwise follow [live.md](live.md)'s first-time setup. Any CSP source edit still requires its stated consent.
|
When image generation is available (context.mjs reports it) and no `buildPath` is recorded yet, ask once how new surfaces should be built, stated as the trade it is: **comp-first** (an image sets the bar before any code; bolder composition, slower, and the build must match the image) or **code-first** (build directly; the ambition is written into the direction contract and audited at the finish; leaner, faster). Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, merging with the keys already there. A value already recorded in `.impeccable/config.json` or the gitignored `.impeccable/config.local.json` is a confirmed answer: on a re-run, honor it in silence rather than asking again. This is a default, not a lock: the decision page renders a toggle whose flip binds a single session and is never written back. Without image generation there is no choice to record; code-first is the only path.
|
||||||
|
|
||||||
|
Then configure live mode when useful: skip native or non-runnable projects and leave existing config untouched. Otherwise follow [live.md](live.md)'s first-time setup. Any CSP source edit still requires its stated consent.
|
||||||
|
|
||||||
## Step 6: Wrap up or resume
|
## Step 6: Wrap up or resume
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -36,19 +36,21 @@ Keep the visual system fixed. Derive five to seven materially different structur
|
|||||||
|
|
||||||
`node .agents/skills/impeccable/scripts/concept-seed.mjs --scope surface --mode <mode>`
|
`node .agents/skills/impeccable/scripts/concept-seed.mjs --scope surface --mode <mode>`
|
||||||
|
|
||||||
The script assigns which structure gets built; your top-ranked structure is what every run would ship, so the dice come from outside. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
The script deals three of your structures to the table; the dice decide which three reach the user, so the ranking rut stays broken while the user still holds a real choice. Present the three dealt structures on the decision page as full cards of equal salience, the dealt lead carrying kicker THE ROLL, with steer and re-roll; the user locks one in. No canon card and no pick card at surface scope: the world is settled, so every card visualizes composition, not identity. With image generation available and a comp-led default (the build-path paragraph below: `.impeccable/config.json`, the toggle handles the exception), each card declares a `comp` under `.impeccable/mocks/decision/`, generated after serving in reading order under the comp discipline in [visualize.md](visualize.md); anchor each of these comps on the established identity by passing a captured screenshot of a representative existing page as a reference image (the harness image tool's input image, or `generate-image.mjs --ref`) beside a prompt that leads with the new surface's structure and names DESIGN.md's palette, type, and component character, because a prose paraphrase of a design system drifts where a pixel reference does not. Without image generation, or under a code-led default, each card instead carries a `wireframe` layout schematic (see `serve-question.mjs --schema`) that the page draws itself. Locking a card is the approval and sets the build path: a locked comp builds comp-led with that comp as the approved comp, discharging [visualize.md](visualize.md)'s three-option round with no second approval point; a locked wireframe builds code-led, its ambition carried by the direction contract. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
||||||
|
|
||||||
### Create or replace the visual world
|
### Create or replace the visual world
|
||||||
|
|
||||||
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.
|
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.
|
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.
|
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.
|
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: 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.
|
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 IMPECCABLE’S 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 `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (the build-path paragraph below owns the details); 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. The fallback to the structured question tool is never yours to predict: run the script, and only exit code 2 from starting it routes the decision there; treat that exit as the 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. 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 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 `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, 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; visualize.md's self-checks bind decision comps identically. Generation takes the same time at any fidelity, so an unfinished draft pays draft 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 execution contract, comp-led or code-led, is a workflow preference, not a per-surface decision, so no round asks it: the recorded default rides every round and the page's toggle handles the exception. Read the default from `.impeccable/config.json` (`buildPath`), with the gitignored `.impeccable/config.local.json` winning where one machine differs from the team's committed value; with neither, comp-led is the default whenever image generation exists. Author every direction and surface payload with `buildPath: { "value": <default>, "toggle": true }`; the page renders a footer toggle with the trade stated beside it, and the ANSWER returns `buildPath` plus `buildPathFlipped`. A flipped value binds that session only and is never written back, with one exception, and it is the only thing inside a round that earns a question about this preference (init records it up front on projects that get the chance): when `buildPathFlipped` comes back true on a project that records no `buildPath` at all, ask once after the round closes whether to keep it as the standing default. Either answer ends in a write to `.impeccable/config.json`; the answer picks the value, never whether to record one. Yes writes the flipped value, and "no, just this once" writes the value they flipped away from, which is the standing default they just confirmed by declining. Ask on the flip and never on the untouched default, because a user who left the toggle alone has told you nothing. A declined offer nothing writes down is an offer the next session makes again. When the user asks in words to change the standing default, update the file without asking. **Comp-led**: the chosen card's comp is law, generated before building when it does not exist yet, and the finish review audits the build against it; boldest composition on the table, fix rounds expected; comp-led 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. A code-led round still declares each card's comp path as a flip reserve: when the user flips the toggle to comp mid-round, `--wait` returns once with BUILD PATH FLIPPED while the page shimmers the slots; generate each open card's comp into its declared path then, lead first, and wait again. The flip back is free, and a comp that already rendered rides at the finish review as the critique reference. Without image generation there is no toggle and no choice: code-led is the only path, stated in one line rather than asked. The old two-card execution-contract round is retired; `followup: true` remains the general mechanism for delivering any later round over the same table via `--update`.
|
||||||
|
|
||||||
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.
|
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,7 +80,7 @@ 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.
|
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.
|
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
|
||||||
|
|
||||||
@@ -101,8 +103,8 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
|||||||
|
|
||||||
## 7. Inspect and finish
|
## 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. 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. 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.
|
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.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Push an interface past conventional limits. This isn't just about visual effects
|
|||||||
This command has the highest potential to misfire. Do NOT jump straight into implementation. You MUST:
|
This command has the highest potential to misfire. Do NOT jump straight into implementation. You MUST:
|
||||||
|
|
||||||
1. **Think through 2-3 different directions**: consider different techniques, levels of ambition, and aesthetic approaches. For each direction, briefly describe what the result would look and feel like.
|
1. **Think through 2-3 different directions**: consider different techniques, levels of ambition, and aesthetic approaches. For each direction, briefly describe what the result would look and feel like.
|
||||||
2. **STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.** to present these directions and get the user's pick before writing any code. Explain trade-offs (browser support, performance cost, complexity).
|
2. **Get the user's pick before writing any code.** STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Carry each direction's description and its trade-offs (browser support, performance cost, complexity) inside the option itself, so the user is choosing between things they can read. A structured question blocks the message it rides in until the user answers, so directions written alongside the question stay invisible while the user is being asked to choose between them.
|
||||||
3. Only proceed with the direction the user confirms.
|
3. Only proceed with the direction the user confirms.
|
||||||
|
|
||||||
Skipping this step risks building something embarrassing that needs to be thrown away.
|
Skipping this step risks building something embarrassing that needs to be thrown away.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Fix the cause at the narrowest correct level. Ask when a binding system principl
|
|||||||
|
|
||||||
## 2. Gather the evidence
|
## 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;
|
- whether the path is functionally complete;
|
||||||
- the intended quality bar and time available;
|
- 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:
|
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;
|
- loading, empty, error, success, disabled, long-content, and missing-content states;
|
||||||
- zoom, contrast, focus, semantics, and screen-reader names;
|
- 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.
|
- 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.
|
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.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Analyze what makes the design feel too intense:
|
|||||||
- What's working? (Don't throw away good ideas)
|
- What's working? (Don't throw away good ideas)
|
||||||
- What's the core message? (Preserve what matters)
|
- What's the core message? (Preserve what matters)
|
||||||
|
|
||||||
If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
|
If any of these are unclear from the codebase, do not guess. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
|
||||||
|
|
||||||
**CRITICAL**: "Quieter" doesn't mean boring or generic. It means refined and easier on the eyes. Think luxury, not laziness.
|
**CRITICAL**: "Quieter" doesn't mean boring or generic. It means refined and easier on the eyes. Think luxury, not laziness.
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
# Visualize: Direction Comps & Asset Production
|
# 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. A surface-scope structure round that already put three visualized cards before the user (new-work.md, established world) has discharged this round: the locked card’s comp is the approved comp, so record the approval and continue at After approval; generate nothing new.
|
||||||
|
|
||||||
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.
|
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
|
## 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. On an established world, anchor every comp on the real identity: capture a screenshot of a representative existing page and pass it as a reference image (the harness image tool’s input image, or `generate-image.mjs --ref`); the prompt then leads with the new surface’s structure while the reference carries palette, type, and component character, because DESIGN.md words alone drift where a pixel reference does not. Name what the reference contributes and what it must not: chrome, palette, type, and component character carry over; the reference page’s own content does not, so a banner, hero, or card lifted verbatim from the reference is the reference leaking, not fidelity. 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.
|
- 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.
|
||||||
|
- The inverse is also a failure: a surface with none of its subject in it. The subject appears as the content the regions exist to hold; the world dresses the frame and never displaces what the frame exists to show. The deletion usually rides in on the prompt's exclusion list, so exclusions bind invented claims, and a medium ban belongs to the committed imagery stance, never to caution. Before accepting a render, point at the subject: a render that depicts everything about the world and nothing of the subject fails however faithful its atmosphere, so regenerate with the subject's content named region by region.
|
||||||
|
- A comp is judged as the shipped screen: the visitor's job must be readable from the image alone. Name the surface's mode from the render with no caption; a render whose mode cannot be read back is art direction without a surface, so regenerate with the visitor's job as the prompt's spine.
|
||||||
|
- Commitment is depth, not coverage. The world enters through one dominant move plus the material, type, and spacing that support it, and the remaining regions hold still so that move can be read; a region that simply does its job in the world's own grammar carries the direction further than a region performing the concept. The check cuts competition, never content: a quieted region keeps its information and stops performing. Where the direction names a focal moment, a second element competing with it at the same scale means the comp is shouting; where it names none, several regions performing the concept at once is the same shout. Regenerate keeping the strongest move and quieting the rest. Busy is louder, not bolder.
|
||||||
- When the user shortlisted multiple concepts, spread the three across them.
|
- When the user shortlisted multiple concepts, spread the three across them.
|
||||||
- When one direction is committed, vary the structural uncertainty an image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
- When one direction is committed, vary the structural uncertainty an image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||||
- Show enough beyond the opening moment to prove the concept can govern the whole requested surface.
|
- Show enough beyond the opening moment to prove the concept can govern the whole requested surface.
|
||||||
@@ -18,11 +21,11 @@ Treat each comp as a direction test, not a screenshot specification. Core UI tex
|
|||||||
|
|
||||||
## One approval point
|
## One approval point
|
||||||
|
|
||||||
Show the three together: in the harness when it can display images, otherwise on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero). Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
Show the three together on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero), or in the harness only when it renders images inline; a text-only surface does not count as display. Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||||
|
|
||||||
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,16 @@
|
|||||||
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
|
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
|
||||||
* fresh assigned index, challengers, and compositions. One base key therefore
|
* fresh assigned index, challengers, and compositions. One base key therefore
|
||||||
* reproduces the entire chain of rounds.
|
* 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
|
* - RATINGS: the reviewer's approval ratings weight the challenger draw
|
||||||
* (3-star doubles the odds, 1-star sits out); the approved pool itself
|
* (3-star doubles the odds, 1-star sits out); the approved pool itself
|
||||||
* is unchanged.
|
* is unchanged.
|
||||||
@@ -41,7 +51,9 @@
|
|||||||
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
|
* 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 --candidate-count 6
|
||||||
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
|
* 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
|
* --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
|
* 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
|
* Challenger data resolves in order: a local catalog directory (the private
|
||||||
* service repo, evals, and tests set IMPECCABLE_CATALOG_DIR), then the roll
|
* 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
|
* API at impeccable.style, then a degraded assignment-only seed when both are
|
||||||
* unavailable. --chosen sends the anonymous choice ping for API-dealt rolls;
|
* unavailable. The anonymous choice ping fires once per resolved attended
|
||||||
* DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables it.
|
* 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:
|
* Env vars:
|
||||||
* IMPECCABLE_CONCEPT_SEED — same as --from; for reproducible eval runs.
|
* 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);
|
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.
|
// Fire-and-forget; never fails the caller.
|
||||||
export async function pingChosen({ chosenId, key, scope, mode }) {
|
const PING_KINDS = new Set(['assigned', 'pick', 'challenger', 'canon']);
|
||||||
if (telemetryDisabled() || !chosenId) return false;
|
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 controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_BASE}/chosen`, {
|
await fetch(`${API_BASE}/chosen`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -260,6 +295,7 @@ export function renderConceptSeed({
|
|||||||
scope = 'surface',
|
scope = 'surface',
|
||||||
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
|
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
|
||||||
reroll = 0,
|
reroll = 0,
|
||||||
|
register = null,
|
||||||
mode = null,
|
mode = null,
|
||||||
grain = null,
|
grain = null,
|
||||||
platform = null,
|
platform = null,
|
||||||
@@ -273,6 +309,15 @@ export function renderConceptSeed({
|
|||||||
if (!Number.isInteger(reroll) || reroll < 0) {
|
if (!Number.isInteger(reroll) || reroll < 0) {
|
||||||
throw new Error('concept-seed: --reroll must be a non-negative integer');
|
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)) {
|
if (mode !== null && !SEED_MODES.has(mode)) {
|
||||||
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
|
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
|
||||||
}
|
}
|
||||||
@@ -293,6 +338,20 @@ export function renderConceptSeed({
|
|||||||
};
|
};
|
||||||
const indexSalt = reroll === 0 ? 'index' : `index:reroll-${reroll}`;
|
const indexSalt = reroll === 0 ? 'index' : `index:reroll-${reroll}`;
|
||||||
const buildIndex = 3 + Math.floor(unit(indexSalt) * (candidateCount - 2)); // 3..candidateCount
|
const buildIndex = 3 + Math.floor(unit(indexSalt) * (candidateCount - 2)); // 3..candidateCount
|
||||||
|
// Surface scope deals a hand of three grounded structures: one card is not
|
||||||
|
// a choice, and the full ranked list would hand selection back to the
|
||||||
|
// model's taste. The dice pick all three; the primary index leads. The
|
||||||
|
// no-lineup rule stays direction-only, where it was written for worlds.
|
||||||
|
const dealtIndices = [buildIndex];
|
||||||
|
for (let draw = 0; scope === 'surface' && dealtIndices.length < Math.min(3, candidateCount); draw += 1) {
|
||||||
|
const idx = 1 + Math.floor(unit(`${indexSalt}:deal-${draw}`) * candidateCount);
|
||||||
|
if (!dealtIndices.includes(idx)) dealtIndices.push(idx);
|
||||||
|
if (draw > 64) { // hash repeats cannot stall the deal
|
||||||
|
for (let fill = 1; dealtIndices.length < Math.min(3, candidateCount); fill += 1) {
|
||||||
|
if (!dealtIndices.includes(fill)) dealtIndices.push(fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Local catalog first (private repo, evals, tests), then the roll API,
|
// Local catalog first (private repo, evals, tests), then the roll API,
|
||||||
// then a degraded assignment-only seed. The assigned index is pure local
|
// then a degraded assignment-only seed. The assigned index is pure local
|
||||||
@@ -326,6 +385,7 @@ export function renderConceptSeed({
|
|||||||
scope,
|
scope,
|
||||||
key,
|
key,
|
||||||
reroll,
|
reroll,
|
||||||
|
register,
|
||||||
mode,
|
mode,
|
||||||
grain,
|
grain,
|
||||||
platform,
|
platform,
|
||||||
@@ -357,16 +417,32 @@ export function renderConceptSeed({
|
|||||||
survive the current task plus navigation, quiet and dense content,
|
survive the current task plus navigation, quiet and dense content,
|
||||||
interaction and state, and a substantially different future surface. In an
|
interaction and state, and a substantially different future surface. In an
|
||||||
attended run, present the assigned direction fully committed and offer
|
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 IMPECCABLE’S 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
|
on named factual grounds, when the assignment cannot carry the product's
|
||||||
truth or task; taste is never grounds.`
|
truth or task; taste is never grounds.`
|
||||||
: `After ordering the task's grounded structural candidates by resonance,
|
: `After ordering the task's grounded structural candidates by resonance,
|
||||||
build candidate ${buildIndex} of your own grounded list; the assignment never
|
deal candidates ${dealtIndices.join(', ')} of your own grounded list to the
|
||||||
points at a challenger. The assignment is the roll, not a suggestion.
|
table; index ${buildIndex} leads, and the deal never points at a challenger.
|
||||||
In an attended run, present the assigned structure and offer re-roll; never
|
The deal is the roll, not a suggestion: the dice decide which structures
|
||||||
present a ranked lineup to choose from. Re-roll yourself only when the
|
reach the user, so the ranking rut stays broken while the user still gets a
|
||||||
assignment fails audience identification or product clarity on named
|
real choice, and the full ranked list stays yours. In an attended run,
|
||||||
factual grounds.`;
|
present the three dealt structures as full cards of equal salience, the
|
||||||
|
lead carrying kicker THE ROLL, with steer and re-roll, and let the user
|
||||||
|
lock one in; the world is already settled, so this choice is composition.
|
||||||
|
Visualize every dealt card: with image generation available and a
|
||||||
|
comp-led default (.impeccable/config.json buildPath; the page toggle
|
||||||
|
handles the exception), declare a comp per card and generate after
|
||||||
|
serving, lead first; otherwise author each card's wireframe field (see
|
||||||
|
serve-question --schema) and the page draws the schematic. Carry the
|
||||||
|
recorded default in the payload as buildPath with toggle: true. Locking a card
|
||||||
|
approves its comp: a surface round that put three visualized structures on
|
||||||
|
the table replaces the three-option comp round in visualize.md. Re-roll
|
||||||
|
yourself only when every dealt structure fails audience identification or
|
||||||
|
product clarity on named factual grounds.`;
|
||||||
|
|
||||||
const challengerInstruction = scope === 'direction'
|
const challengerInstruction = scope === 'direction'
|
||||||
? `Fuse each challenger before judging it: the challenger supplies the form
|
? `Fuse each challenger before judging it: the challenger supplies the form
|
||||||
@@ -374,7 +450,16 @@ export function renderConceptSeed({
|
|||||||
conflicts. Weigh the fused result against the assigned direction on exactly
|
conflicts. Weigh the fused result against the assigned direction on exactly
|
||||||
two axes, audience identification and product clarity. Losing to strong
|
two axes, audience identification and product clarity. Losing to strong
|
||||||
grounded material is a valid outcome; beating a thin or tool-monoculture
|
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
|
: `A challenger wins only when its fused result beats the grounded list on
|
||||||
audience identification and product clarity. It may change task topology or
|
audience identification and product clarity. It may change task topology or
|
||||||
interaction, but never the committed visual identity.`;
|
interaction, but never the committed visual identity.`;
|
||||||
@@ -399,8 +484,39 @@ Ambitious motion, spatial media, or interaction is welcome when it strengthens
|
|||||||
the product without weakening semantics, performance, or fallback behavior.`;
|
the product without weakening semantics, performance, or fallback behavior.`;
|
||||||
|
|
||||||
if (!data) {
|
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})
|
// A degraded roll can still serve the safer register, which needs no
|
||||||
ASSIGNED INDEX: ${buildIndex}
|
// 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}${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
|
||||||
${promotedInstruction}
|
${promotedInstruction}
|
||||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
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.
|
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||||
@@ -424,8 +540,11 @@ channel: when a browser can open, present the direction on the decision page
|
|||||||
the no-browser fallback.
|
the no-browser fallback.
|
||||||
${authorityInstruction}
|
${authorityInstruction}
|
||||||
A user- or brief-pinned decision beats the roll, always.
|
A user- or brief-pinned decision beats the roll, always.
|
||||||
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
${scope === 'direction'
|
||||||
${buildIndex} of your own grounded list; seed key ${key}.
|
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||||
|
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||||
|
: `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index
|
||||||
|
${buildIndex} leads. Present all three dealt structures; seed key ${key}.`}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,34 +590,79 @@ 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`
|
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
|
||||||
: '';
|
: '';
|
||||||
const rerollBlock = reroll > 0
|
const rerollBlock = reroll > 0
|
||||||
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
|
? `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. Derive
|
and challenger alike, is eliminated and may not return reworded.${register ? '' : ` Derive
|
||||||
genuinely new grounded candidates from unexplored angles before judging
|
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'
|
const telemetryBlock = data.source === 'api'
|
||||||
? `TELEMETRY: if the resolved direction uses one of these challengers, rerun
|
? `TELEMETRY: after the user's choice resolves, rerun this script once with
|
||||||
this script once with --chosen <challenger-id> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''}
|
--kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''},
|
||||||
after resolution. The ping is anonymous (chosen id only) and is skipped
|
adding --chosen <challenger-id> when a dealt challenger won and keeping
|
||||||
automatically when DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY is set.\n`
|
--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)
|
const assignedBlock = register === null
|
||||||
${rerollBlock}ASSIGNED INDEX: ${buildIndex}
|
? `${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
|
||||||
${promotedInstruction}
|
${promotedInstruction}
|
||||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
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.
|
the user or the brief. Never expose assignment metadata in user-facing labels.`
|
||||||
CHALLENGERS:
|
: 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')}
|
${data.challengers.map(renderChallenger).join('\n')}
|
||||||
${compositionBlock}${challengerInstruction}
|
${compositionBlock}${roundChallengerInstruction}
|
||||||
When you can view images, open the QUALITY BAR board and hero for any
|
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
|
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,
|
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.
|
never as a mockup to copy; your surface serves this product, not that render.
|
||||||
${authorityInstruction}
|
`;
|
||||||
|
const restated = register === null
|
||||||
|
? (scope === 'direction'
|
||||||
|
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||||
|
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||||
|
: `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index
|
||||||
|
${buildIndex} leads. Present all three dealt structures; 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}
|
${richnessInstruction}
|
||||||
${telemetryBlock}A user- or brief-pinned decision beats the roll, always.
|
${telemetryBlock}A user- or brief-pinned decision beats the roll, always.
|
||||||
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
${restated}
|
||||||
${buildIndex} of your own grounded list; seed key ${key}.
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,19 +671,25 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
|||||||
const fromIdx = args.indexOf('--from');
|
const fromIdx = args.indexOf('--from');
|
||||||
const scopeIdx = args.indexOf('--scope');
|
const scopeIdx = args.indexOf('--scope');
|
||||||
const rerollIdx = args.indexOf('--reroll');
|
const rerollIdx = args.indexOf('--reroll');
|
||||||
|
const registerIdx = args.indexOf('--register');
|
||||||
const modeIdx = args.indexOf('--mode');
|
const modeIdx = args.indexOf('--mode');
|
||||||
const grainIdx = args.indexOf('--grain');
|
const grainIdx = args.indexOf('--grain');
|
||||||
const platformIdx = args.indexOf('--platform');
|
const platformIdx = args.indexOf('--platform');
|
||||||
const candidateCountIdx = args.indexOf('--candidate-count');
|
const candidateCountIdx = args.indexOf('--candidate-count');
|
||||||
const chosenIdx = args.indexOf('--chosen');
|
const chosenIdx = args.indexOf('--chosen');
|
||||||
|
const kindIdx = args.indexOf('--kind');
|
||||||
try {
|
try {
|
||||||
if (chosenIdx !== -1) {
|
if (chosenIdx !== -1 || kindIdx !== -1) {
|
||||||
// Choice ping: always exits 0, telemetry must never fail a design flow.
|
// 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({
|
const sent = await pingChosen({
|
||||||
chosenId: args[chosenIdx + 1],
|
chosenId: chosenIdx !== -1 ? args[chosenIdx + 1] : undefined,
|
||||||
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
|
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
|
||||||
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
|
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
|
||||||
mode: modeIdx !== -1 ? args[modeIdx + 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');
|
process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n');
|
||||||
} else {
|
} else {
|
||||||
@@ -542,6 +712,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
|||||||
? args[fromIdx + 1]
|
? args[fromIdx + 1]
|
||||||
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
|
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
|
||||||
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
|
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
|
||||||
|
register: registerIdx !== -1 ? args[registerIdx + 1] : null,
|
||||||
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
|
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
|
||||||
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
|
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
|
||||||
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
|
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
|
||||||
@@ -553,6 +724,13 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
|||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
// A raced-out fetch may still hold a socket; exit explicitly so the CLI
|
// 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);
|
process.exit(process.exitCode ?? 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,14 +42,15 @@ function latestCritique(cwd) {
|
|||||||
if (!latest) return null;
|
if (!latest) return null;
|
||||||
const get = (key) => latest.meta[key] ?? null;
|
const get = (key) => latest.meta[key] ?? null;
|
||||||
const num = (v) => {
|
const num = (v) => {
|
||||||
|
if (v == null || (typeof v === 'string' && v.trim() === '')) return null;
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
slug: get('slug'),
|
slug: get('slug'),
|
||||||
score: num(get('score')),
|
score: num(get('total_score') ?? get('score')),
|
||||||
p0: num(get('p0')),
|
p0: num(get('p0_count') ?? get('p0')),
|
||||||
p1: num(get('p1')),
|
p1: num(get('p1_count') ?? get('p1')),
|
||||||
timestamp: get('timestamp'),
|
timestamp: get('timestamp'),
|
||||||
file: path.relative(cwd, latest.path),
|
file: path.relative(cwd, latest.path),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1013,14 +1013,22 @@ async function fetchLatestSkillVersion() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||||
|
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||||
|
// continue without waiting, so a run that could never establish agreement was
|
||||||
|
// still spelled out as the next command. The offer stays; the command leaves
|
||||||
|
// this turn entirely, because installing over the skill mid-session changes
|
||||||
|
// files the session is reading and only takes effect in the next one anyway.
|
||||||
function buildUpdateDirective(localVersion, latestVersion) {
|
function buildUpdateDirective(localVersion, latestVersion) {
|
||||||
return (
|
return (
|
||||||
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
|
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
|
||||||
`(installed v${localVersion}, latest v${latestVersion}). ` +
|
`(installed v${localVersion}, latest v${latestVersion}). ` +
|
||||||
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
|
`Mention it once, in this form: "A newer Impeccable (v${latestVersion}) is available. ` +
|
||||||
`Update now? It runs \`npx impeccable update\`." ` +
|
`Update now? It runs \`npx impeccable update\`." ` +
|
||||||
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
|
`Do not run \`npx impeccable update\` in this turn, whatever the user answers: it rewrites the skill files ` +
|
||||||
`Either way, continue the current task without waiting, and do not raise this again.`
|
`this session is reading, and the update only takes effect in the next session, so there is nothing to gain now. ` +
|
||||||
|
`Run it in a later turn, only after the user has asked for it in their own words. ` +
|
||||||
|
`Continue the current task now without waiting, and do not raise this again.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1142,6 +1150,7 @@ async function cli() {
|
|||||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||||
appendDetectorFallback(parts, ctx);
|
appendDetectorFallback(parts, ctx);
|
||||||
appendImageGenDirective(parts);
|
appendImageGenDirective(parts);
|
||||||
|
appendBuildPathDirective(parts, ctx);
|
||||||
appendAutonomyCounterDirective(parts);
|
appendAutonomyCounterDirective(parts);
|
||||||
appendSubagentAuthorizationDirective(parts);
|
appendSubagentAuthorizationDirective(parts);
|
||||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||||
@@ -1161,6 +1170,7 @@ async function cli() {
|
|||||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||||
appendDetectorFallback(parts, ctx);
|
appendDetectorFallback(parts, ctx);
|
||||||
appendImageGenDirective(parts);
|
appendImageGenDirective(parts);
|
||||||
|
appendBuildPathDirective(parts, ctx);
|
||||||
appendAutonomyCounterDirective(parts);
|
appendAutonomyCounterDirective(parts);
|
||||||
appendSubagentAuthorizationDirective(parts);
|
appendSubagentAuthorizationDirective(parts);
|
||||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||||
@@ -1269,6 +1279,53 @@ function automaticHookMode(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Build-path preference: a workflow setting (comp-led vs code-led), read here
|
||||||
|
// so every session starts knowing it without a file hunt. It rides the unified
|
||||||
|
// config beside the hook and detector settings, and the gitignored local file
|
||||||
|
// wins, because whether a machine has an image tool is a property of that
|
||||||
|
// machine, not of the team's committed default. Absence stays silent;
|
||||||
|
// new-work's own default applies, and the decision page toggle can flip the
|
||||||
|
// value for a single session.
|
||||||
|
function readBuildPathAt(root) {
|
||||||
|
let value = null;
|
||||||
|
let source = null;
|
||||||
|
for (const name of ['config.json', 'config.local.json']) {
|
||||||
|
const raw = readJson(path.join(root, '.impeccable', name));
|
||||||
|
if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') {
|
||||||
|
value = raw.buildPath;
|
||||||
|
source = `.impeccable/${name}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value ? { value, source } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roots in precedence order, nearest first: the resolved project decides, and
|
||||||
|
// the repo root is the fallback a monorepo commits once for every app in it.
|
||||||
|
// `checkBuildPathUnset` reads exactly these two, and the pair has to match:
|
||||||
|
// when they disagree the finding goes silent because a value exists while the
|
||||||
|
// directive never names it, which is the one combination nobody can debug.
|
||||||
|
//
|
||||||
|
// The invoking directory is deliberately not in the chain. With `--target`
|
||||||
|
// selecting another workspace, cwd is the caller's app, not the target's, and
|
||||||
|
// letting it rank above the repo root hands one workspace another's workflow.
|
||||||
|
// It stands in only when no project resolved at all.
|
||||||
|
function appendBuildPathDirective(parts, ctx) {
|
||||||
|
const roots = [...new Set(
|
||||||
|
[ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((root) => path.resolve(root)),
|
||||||
|
)];
|
||||||
|
for (const root of roots) {
|
||||||
|
const found = readBuildPathAt(root);
|
||||||
|
if (!found) continue;
|
||||||
|
// "Never written back" is scoped by the fact that this directive exists at
|
||||||
|
// all: it is emitted only where a value is already recorded, which is the
|
||||||
|
// case where a flip really is session-only. Saying so inline because the
|
||||||
|
// bare absolute reads as a rule that overrides new-work's one-time offer,
|
||||||
|
// which is exactly how the same wording misfired in serve-question.
|
||||||
|
parts.push(`BUILD_PATH_DEFAULT: ${found.value} (from ${found.source}). Author direction and surface rounds with this as buildPath.value and toggle: true; a flip on the page binds that session only and is never written back, because a default is already recorded here. New-work's one-time offer to record a flipped value applies only where no default exists, which is why you are not seeing this line on those projects.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Image generation availability: harness-native tools always win, but when the
|
// Image generation availability: harness-native tools always win, but when the
|
||||||
// environment carries an OpenAI key the API fallback works everywhere. The
|
// environment carries an OpenAI key the API fallback works everywhere. The
|
||||||
// flag only reports capability, positively: absence stays silent, because a
|
// flag only reports capability, positively: absence stays silent, because a
|
||||||
|
|||||||
@@ -683,6 +683,10 @@ if (IS_BROWSER) {
|
|||||||
|
|
||||||
const reasons = collectVisualContrastReasons(el, style);
|
const reasons = collectVisualContrastReasons(el, style);
|
||||||
if (reasons.length === 0) continue;
|
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 textColor = parseRgb(style.color);
|
||||||
const fontSize = parseFloat(style.fontSize) || 16;
|
const fontSize = parseFloat(style.fontSize) || 16;
|
||||||
@@ -1175,6 +1179,7 @@ if (IS_BROWSER) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function analyzeVisualContrast(options = {}) {
|
async function analyzeVisualContrast(options = {}) {
|
||||||
|
// imageOnly is enforced inside the collector, before the candidate cap.
|
||||||
const candidates = collectVisualContrastCandidates(options);
|
const candidates = collectVisualContrastCandidates(options);
|
||||||
const results = [];
|
const results = [];
|
||||||
const shouldScrollOffscreen = options.scrollOffscreen === true;
|
const shouldScrollOffscreen = options.scrollOffscreen === true;
|
||||||
@@ -1260,9 +1265,16 @@ if (IS_BROWSER) {
|
|||||||
|
|
||||||
function addBrowserFindings(groupMap, el, findings) {
|
function addBrowserFindings(groupMap, el, findings) {
|
||||||
if (!findings || findings.length === 0) return;
|
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);
|
const existing = groupMap.get(el);
|
||||||
if (existing) existing.push(...findings);
|
if (existing) existing.push(...kept);
|
||||||
else groupMap.set(el, [...findings]);
|
else groupMap.set(el, [...kept]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function browserFindingsFromMap(groupMap) {
|
function browserFindingsFromMap(groupMap) {
|
||||||
@@ -1620,9 +1632,27 @@ if (IS_BROWSER) {
|
|||||||
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
|
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
|
||||||
node.remove();
|
node.remove();
|
||||||
}
|
}
|
||||||
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
|
// Regex findings that name a live selector resolve against the real DOM:
|
||||||
if (htmlPatternFindings.length > 0) {
|
// pseudo-element/class segments are stripped (the host element is the
|
||||||
const mapped = htmlPatternFindings.map(f => {
|
// 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 };
|
const item = { type: f.id, detail: f.snippet };
|
||||||
if (f.severity) {
|
if (f.severity) {
|
||||||
item.severity = 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 = {}) {
|
function shouldRunVisualContrast(options = {}) {
|
||||||
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
|
return visualContrastMode(options) !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function visualContrastOptions(options = {}) {
|
function visualContrastOptions(options = {}) {
|
||||||
@@ -1830,6 +1879,7 @@ if (IS_BROWSER) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const resolvedOptions = visualContrastOptions(options);
|
const resolvedOptions = visualContrastOptions(options);
|
||||||
|
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
|
||||||
const analyses = await analyzeVisualContrast(resolvedOptions);
|
const analyses = await analyzeVisualContrast(resolvedOptions);
|
||||||
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
|
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
|
||||||
lastVisualContrastAnalyses = analyses;
|
lastVisualContrastAnalyses = analyses;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
|||||||
// boundaries; `.impeccable` is our own project marker.
|
// boundaries; `.impeccable` is our own project marker.
|
||||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||||
|
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||||
|
// difference between a documented shadow and drift), so shadow matching cannot
|
||||||
|
// reuse the r/g/b-only channel tolerance.
|
||||||
|
const SHADOW_ALPHA_TOLERANCE = 0.02;
|
||||||
const RADIUS_TOLERANCE_PX = 0.5;
|
const RADIUS_TOLERANCE_PX = 0.5;
|
||||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||||
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
||||||
@@ -474,6 +478,25 @@ function addSidecarRadii(out, sidecar) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidecar `extensions.shadows` entries ({ name, value, purpose }) carry the
|
||||||
|
// documented shadow vocabulary that Stitch's frontmatter schema can't hold.
|
||||||
|
// Their colors go into a separate allowlist — NOT allowedColorKeys — because a
|
||||||
|
// shadow black is only documented *as a shadow*: feeding it into the general
|
||||||
|
// color allowlist would legalize #000 as a page ground (alpha is dropped from
|
||||||
|
// colorKey), which is the hole issue #547 warns against.
|
||||||
|
function addSidecarShadows(out, sidecar) {
|
||||||
|
const shadows = sidecar?.extensions?.shadows;
|
||||||
|
if (!Array.isArray(shadows)) return;
|
||||||
|
|
||||||
|
for (const entry of shadows) {
|
||||||
|
if (typeof entry?.value !== 'string') continue;
|
||||||
|
for (const match of entry.value.matchAll(CSS_COLOR_RE)) {
|
||||||
|
const parsed = parseDesignColor(match[0]);
|
||||||
|
if (parsed) out.allowedShadowColors.push({ color: parsed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDesignSystem(input = {}) {
|
function normalizeDesignSystem(input = {}) {
|
||||||
const frontmatter = input.frontmatter || {};
|
const frontmatter = input.frontmatter || {};
|
||||||
const sidecar = input.sidecar || null;
|
const sidecar = input.sidecar || null;
|
||||||
@@ -486,6 +509,7 @@ function normalizeDesignSystem(input = {}) {
|
|||||||
allowedColorKeys: new Map(),
|
allowedColorKeys: new Map(),
|
||||||
allowedRadii: [],
|
allowedRadii: [],
|
||||||
allowedFontSizes: [],
|
allowedFontSizes: [],
|
||||||
|
allowedShadowColors: [],
|
||||||
hasPillRadius: false,
|
hasPillRadius: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -495,6 +519,7 @@ function normalizeDesignSystem(input = {}) {
|
|||||||
addSidecarColors(out, sidecar);
|
addSidecarColors(out, sidecar);
|
||||||
addRoundedScale(out, frontmatter.rounded);
|
addRoundedScale(out, frontmatter.rounded);
|
||||||
addSidecarRadii(out, sidecar);
|
addSidecarRadii(out, sidecar);
|
||||||
|
addSidecarShadows(out, sidecar);
|
||||||
|
|
||||||
out.hasFonts = out.allowedFonts.size > 0;
|
out.hasFonts = out.allowedFonts.size > 0;
|
||||||
out.hasColors = out.allowedColorKeys.size > 0;
|
out.hasColors = out.allowedColorKeys.size > 0;
|
||||||
@@ -614,6 +639,20 @@ function isAllowedColorRaw(raw, designSystem) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A color is a documented shadow color only when both the r/g/b channels AND
|
||||||
|
// the alpha match a sidecar shadow token's color. Alpha has to be compared
|
||||||
|
// here because colorKey()/colorsClose() drop it, and a match on r/g/b alone
|
||||||
|
// would let every black at every alpha through.
|
||||||
|
function isAllowedShadowColorRaw(raw, designSystem) {
|
||||||
|
if (!designSystem?.allowedShadowColors?.length) return false;
|
||||||
|
const parsed = parseDesignColor(String(raw || '').trim().toLowerCase());
|
||||||
|
if (!parsed) return false;
|
||||||
|
return designSystem.allowedShadowColors.some(entry =>
|
||||||
|
colorsClose(parsed, entry.color) &&
|
||||||
|
Math.abs((parsed.a ?? 1) - (entry.color.a ?? 1)) <= SHADOW_ALPHA_TOLERANCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isAllowedRadiusRaw(raw, designSystem) {
|
function isAllowedRadiusRaw(raw, designSystem) {
|
||||||
if (!designSystem?.hasRadii) return true;
|
if (!designSystem?.hasRadii) return true;
|
||||||
const text = String(raw || '').trim().toLowerCase();
|
const text = String(raw || '').trim().toLowerCase();
|
||||||
@@ -691,6 +730,40 @@ function isProbablyColorLiteral(line, match) {
|
|||||||
return styleContext || cssFunctionContext || jsColorKeyContext;
|
return styleContext || cssFunctionContext || jsColorKeyContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One complete `${...}` template interpolation. Its content may carry paired
|
||||||
|
// quoted strings (function arguments, ternary branches) and one level of
|
||||||
|
// braces (an object-literal argument, itself allowing paired quotes). Deeper
|
||||||
|
// nesting would need a parser, so the regex deliberately fails safe there:
|
||||||
|
// the context check misses and the finding fires — a false positive a waiver
|
||||||
|
// can silence, never a leak.
|
||||||
|
const QUOTED_STRING_SRC = `"[^"]*"|'[^']*'`;
|
||||||
|
const INTERPOLATION_SRC =
|
||||||
|
`\\$\\{(?:${QUOTED_STRING_SRC}|\\{(?:${QUOTED_STRING_SRC}|[^{}"'\`])*\\}|[^{}"'\`])*\\}`;
|
||||||
|
// The two shadow-context tails. Unlike jsColorKeyContext, the JS tail admits
|
||||||
|
// commas: a multi-layer shadow string is comma-separated, and a later
|
||||||
|
// property on the same line is still blocked because it sits past the
|
||||||
|
// string's closing quote. Both tails admit complete interpolations; a bare
|
||||||
|
// `}`, quote, or `;` still ends the context.
|
||||||
|
const SHADOW_CSS_CONTEXT_RE = new RegExp(
|
||||||
|
`(?:^|[{\\s;"'\`(,])(?:box-shadow|text-shadow)\\s*:\\s*(?:${INTERPOLATION_SRC}|[^;{}"'\`])*$`, 'i',
|
||||||
|
);
|
||||||
|
const SHADOW_JS_CONTEXT_RE = new RegExp(
|
||||||
|
`(?:^|[,{]\\s*)(?:boxShadow|textShadow)\\s*[:=]\\s*["'\`]?(?:${INTERPOLATION_SRC}|[^"'\`}])*$`, 'i',
|
||||||
|
);
|
||||||
|
|
||||||
|
// True when the color literal sits inside a box-shadow / text-shadow value —
|
||||||
|
// the only contexts where a documented shadow color is legal. Anchored to the
|
||||||
|
// end of `before` (no ; } { or quote in between) so a shadow property earlier
|
||||||
|
// on the line can't leak the allowance into a later declaration. Kept separate
|
||||||
|
// from isProbablyColorLiteral(), which stays a boolean for its existing call
|
||||||
|
// sites and deliberately discards which property matched.
|
||||||
|
function isShadowPropertyContext(line, match) {
|
||||||
|
const index = match.index ?? -1;
|
||||||
|
if (index < 0) return false;
|
||||||
|
const before = line.slice(0, index);
|
||||||
|
return SHADOW_CSS_CONTEXT_RE.test(before) || SHADOW_JS_CONTEXT_RE.test(before);
|
||||||
|
}
|
||||||
|
|
||||||
function isInsideCssAttributeSelector(line, index) {
|
function isInsideCssAttributeSelector(line, index) {
|
||||||
if (index < 0) return false;
|
if (index < 0) return false;
|
||||||
const before = line.slice(0, index);
|
const before = line.slice(0, index);
|
||||||
@@ -824,6 +897,7 @@ function checkSourceDesignSystem(content, filePath, options = {}) {
|
|||||||
if (!isProbablyColorLiteral(line, match)) continue;
|
if (!isProbablyColorLiteral(line, match)) continue;
|
||||||
const raw = cssColorLabel(match[0]);
|
const raw = cssColorLabel(match[0]);
|
||||||
if (isAllowedColorRaw(raw, designSystem)) continue;
|
if (isAllowedColorRaw(raw, designSystem)) continue;
|
||||||
|
if (isShadowPropertyContext(line, match) && isAllowedShadowColorRaw(raw, designSystem)) continue;
|
||||||
findings.push(makeDesignFinding(
|
findings.push(makeDesignFinding(
|
||||||
'design-system-color',
|
'design-system-color',
|
||||||
filePath,
|
filePath,
|
||||||
@@ -1038,6 +1112,7 @@ export {
|
|||||||
loadDesignSystemForCwd,
|
loadDesignSystemForCwd,
|
||||||
isAllowedFont,
|
isAllowedFont,
|
||||||
isAllowedColorRaw,
|
isAllowedColorRaw,
|
||||||
|
isAllowedShadowColorRaw,
|
||||||
isAllowedRadiusRaw,
|
isAllowedRadiusRaw,
|
||||||
isAllowedFontSizeRaw,
|
isAllowedFontSizeRaw,
|
||||||
checkSourceDesignSystem,
|
checkSourceDesignSystem,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -425,25 +425,28 @@ const REGEX_MATCHERS = [
|
|||||||
},
|
},
|
||||||
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
|
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
|
||||||
// --- Layout property transition ---
|
// --- Layout property transition ---
|
||||||
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
|
// JSX inline style objects use comma-delimited quoted values, not semicolons (issue #548).
|
||||||
|
{ id: 'layout-transition', regex: /transition\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi,
|
||||||
test: (m) => {
|
test: (m) => {
|
||||||
const val = m[1].toLowerCase();
|
const val = (m[2] ?? m[3] ?? '').toLowerCase();
|
||||||
if (/\ball\b/.test(val)) return false;
|
if (/\ball\b/.test(val)) return false;
|
||||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||||
},
|
},
|
||||||
fmt: (m) => {
|
fmt: (m) => {
|
||||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
const raw = m[2] ?? m[3] ?? '';
|
||||||
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
|
const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||||
|
return `transition: ${found ? found.join(', ') : raw.trim()}`;
|
||||||
} },
|
} },
|
||||||
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
|
{ id: 'layout-transition', regex: /transition-property\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi,
|
||||||
test: (m) => {
|
test: (m) => {
|
||||||
const val = m[1].toLowerCase();
|
const val = (m[2] ?? m[3] ?? '').toLowerCase();
|
||||||
if (/\ball\b/.test(val)) return false;
|
if (/\ball\b/.test(val)) return false;
|
||||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||||
},
|
},
|
||||||
fmt: (m) => {
|
fmt: (m) => {
|
||||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
const raw = m[2] ?? m[3] ?? '';
|
||||||
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
|
const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||||
|
return `transition-property: ${found ? found.join(', ') : raw.trim()}`;
|
||||||
} },
|
} },
|
||||||
// --- Broken image: src="" or src="#" or src=" " ---
|
// --- Broken image: src="" or src="#" or src=" " ---
|
||||||
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
|
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ const STATIC_INHERITED_PROPS = new Set([
|
|||||||
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
|
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
|
||||||
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
|
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
|
||||||
'webkitHyphens',
|
'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 = {
|
const STATIC_DEFAULT_STYLE = {
|
||||||
@@ -278,6 +282,7 @@ const STATIC_DEFAULT_STYLE = {
|
|||||||
marginLeft: '0px',
|
marginLeft: '0px',
|
||||||
position: 'static',
|
position: 'static',
|
||||||
visibility: 'visible',
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
top: 'auto',
|
top: 'auto',
|
||||||
right: 'auto',
|
right: 'auto',
|
||||||
bottom: 'auto',
|
bottom: 'auto',
|
||||||
@@ -334,6 +339,7 @@ const STATIC_PROP_MAP = {
|
|||||||
'margin-left': 'marginLeft',
|
'margin-left': 'marginLeft',
|
||||||
'position': 'position',
|
'position': 'position',
|
||||||
'visibility': 'visibility',
|
'visibility': 'visibility',
|
||||||
|
'opacity': 'opacity',
|
||||||
'top': 'top',
|
'top': 'top',
|
||||||
'right': 'right',
|
'right': 'right',
|
||||||
'bottom': 'bottom',
|
'bottom': 'bottom',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
checkCreamPalette,
|
checkCreamPalette,
|
||||||
checkHtmlPatterns,
|
checkHtmlPatterns,
|
||||||
checkKickerAboveHeadingFromDoc,
|
checkKickerAboveHeadingFromDoc,
|
||||||
|
scopedIgnoreActive,
|
||||||
checkNumberedSectionLabelsFromDoc,
|
checkNumberedSectionLabelsFromDoc,
|
||||||
checkPageLayout,
|
checkPageLayout,
|
||||||
checkPageQualityFromDoc,
|
checkPageQualityFromDoc,
|
||||||
@@ -138,10 +139,21 @@ async function detectHtml(filePath, options = {}) {
|
|||||||
domutils,
|
domutils,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
return detectText(html, filePath, options);
|
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 resolvedPath = path.resolve(filePath);
|
||||||
const fileDir = path.dirname(resolvedPath);
|
const fileDir = path.dirname(resolvedPath);
|
||||||
const root = profileStep(profile, {
|
const root = profileStep(profile, {
|
||||||
@@ -171,6 +183,9 @@ async function detectHtml(filePath, options = {}) {
|
|||||||
const tag = el.tagName.toLowerCase();
|
const tag = el.tagName.toLowerCase();
|
||||||
const style = window.getComputedStyle(el);
|
const style = window.getComputedStyle(el);
|
||||||
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
|
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));
|
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 =>
|
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html, patternCorpora).filter(item =>
|
||||||
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
|
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);
|
const item = finding(f.id, filePath, f.snippet);
|
||||||
// Position-aware severity promotion: checks may attach a per-finding
|
// Position-aware severity promotion: checks may attach a per-finding
|
||||||
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
||||||
|
|||||||
@@ -11,14 +11,21 @@ import {
|
|||||||
isBrandFontOnOwnDomain,
|
isBrandFontOnOwnDomain,
|
||||||
} from '../shared/constants.mjs';
|
} from '../shared/constants.mjs';
|
||||||
import {
|
import {
|
||||||
|
CSS_NAMED_COLORS,
|
||||||
colorToHex,
|
colorToHex,
|
||||||
|
compositeColorOver,
|
||||||
contrastRatio,
|
contrastRatio,
|
||||||
getHue,
|
getHue,
|
||||||
hasChroma,
|
hasChroma,
|
||||||
isNeutralColor,
|
isNeutralColor,
|
||||||
|
isNoPaintColorValue,
|
||||||
|
oklchToRgb,
|
||||||
|
parseAnyColor,
|
||||||
|
parseColorMix,
|
||||||
parseGradientColors,
|
parseGradientColors,
|
||||||
parseRgb,
|
parseRgb,
|
||||||
relativeLuminance,
|
relativeLuminance,
|
||||||
|
splitTopLevelCommas,
|
||||||
} from '../shared/color.mjs';
|
} from '../shared/color.mjs';
|
||||||
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
|
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
|
||||||
|
|
||||||
@@ -70,6 +77,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
|
|||||||
return findings;
|
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
|
// Returns true if the given text is composed entirely of emoji characters
|
||||||
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
|
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
|
||||||
// regardless of CSS `color`, so contrast checks against the element's text
|
// regardless of CSS `color`, so contrast checks against the element's text
|
||||||
@@ -637,6 +672,26 @@ function cssTextHasDarkRootBg(content, customProps) {
|
|||||||
return false;
|
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) {
|
function scanCssTextForGlow(content) {
|
||||||
const customProps = collectCssCustomProps(content);
|
const customProps = collectCssCustomProps(content);
|
||||||
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
|
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
|
||||||
@@ -948,6 +1003,7 @@ function scanCssTextForPseudoStripe(rawContent) {
|
|||||||
id: 'side-tab',
|
id: 'side-tab',
|
||||||
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
||||||
index: selectorStart,
|
index: selectorStart,
|
||||||
|
selector,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return findings;
|
return findings;
|
||||||
@@ -1010,6 +1066,7 @@ function scanCssTextForInsetStripe(content) {
|
|||||||
findings.push({
|
findings.push({
|
||||||
id: 'side-tab',
|
id: 'side-tab',
|
||||||
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
|
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
|
||||||
|
selector,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1067,7 +1124,7 @@ function collectMarqueeKeyframes(content) {
|
|||||||
function scanCssTextForMarquee(content, markup = content) {
|
function scanCssTextForMarquee(content, markup = content) {
|
||||||
const findings = [];
|
const findings = [];
|
||||||
if (/<marquee\b/i.test(markup)) {
|
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);
|
const marqueeKeyframes = collectMarqueeKeyframes(content);
|
||||||
if (marqueeKeyframes.size === 0) return findings;
|
if (marqueeKeyframes.size === 0) return findings;
|
||||||
@@ -1082,7 +1139,7 @@ function scanCssTextForMarquee(content, markup = content) {
|
|||||||
const key = `${selector} ${name}`;
|
const key = `${selector} ${name}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
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;
|
return findings;
|
||||||
@@ -1453,8 +1510,10 @@ function checkHtmlPatterns(html, corpora) {
|
|||||||
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
||||||
if (purpleHexRe.test(styleText)) {
|
if (purpleHexRe.test(styleText)) {
|
||||||
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
|
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
|
||||||
if (purpleTextRe.test(styleText)) {
|
purpleTextRe.lastIndex = 0;
|
||||||
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
|
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 start = Math.max(0, gm.index - 200);
|
||||||
const context = styleText.substring(start, gm.index + gm[0].length + 200);
|
const context = styleText.substring(start, gm.index + gm[0].length + 200);
|
||||||
if (/gradient/i.test(context)) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1531,7 +1590,7 @@ function checkHtmlPatterns(html, corpora) {
|
|||||||
const animationToken = bounceMatch[1]
|
const animationToken = bounceMatch[1]
|
||||||
.split(/[,\s]+/)
|
.split(/[,\s]+/)
|
||||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
.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
|
// Overshoot cubic-bezier
|
||||||
@@ -1540,7 +1599,7 @@ function checkHtmlPatterns(html, corpora) {
|
|||||||
while ((bm = bezierRe.exec(styleText)) !== null) {
|
while ((bm = bezierRe.exec(styleText)) !== null) {
|
||||||
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
|
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
|
||||||
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1573,18 +1632,21 @@ function checkHtmlPatterns(html, corpora) {
|
|||||||
|
|
||||||
const glowHits = scanCssTextForGlow(styleText);
|
const glowHits = scanCssTextForGlow(styleText);
|
||||||
if (glowHits.length > 0) {
|
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)
|
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
|
||||||
const haloHits = scanCssTextForRadialHalo(styleText);
|
const haloHits = scanCssTextForRadialHalo(styleText);
|
||||||
if (haloHits.length > 0) {
|
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 ---
|
// --- 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 ---
|
// --- Generated-UI tells: two-axis grid-line background ---
|
||||||
@@ -1602,7 +1664,7 @@ function checkHtmlPatterns(html, corpora) {
|
|||||||
// whole gradient layers.
|
// whole gradient layers.
|
||||||
const gridHits = scanCssTextForGridBackground(styleText);
|
const gridHits = scanCssTextForGridBackground(styleText);
|
||||||
if (gridHits.length > 0) {
|
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 ---
|
// --- 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
|
// hover:rotate / hover:translate utility on an <img>. Each distinct
|
||||||
// mechanism is its own finding.
|
// mechanism is its own finding.
|
||||||
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
|
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;
|
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
|
||||||
let im;
|
let im;
|
||||||
@@ -1670,7 +1735,46 @@ function readOwnBackgroundColor(el, computedStyle) {
|
|||||||
return bg;
|
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;
|
let current = el;
|
||||||
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
|
// 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
|
// base. A browser composites these over the base; the old behavior
|
||||||
@@ -1698,67 +1802,114 @@ function resolveBackground(el, win, customPropMap) {
|
|||||||
// body backgrounds.
|
// body backgrounds.
|
||||||
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
|
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
|
||||||
// (e.g. any color-mix() result), which plain parseRgb misses.
|
// (e.g. any color-mix() result), which plain parseRgb misses.
|
||||||
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
let bg = readCascadeBackgroundColor(current, style, customPropMap);
|
||||||
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
|
|
||||||
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
|
// `background-color: currentcolor` paints with the element's own text
|
||||||
// through customPropMap so Tailwind v4 color tokens become RGB.
|
// color — real paint whose value we know. Real browsers resolve the
|
||||||
if (customPropMap) {
|
// keyword before getComputedStyle output; jsdom hands it through
|
||||||
bg = parseColorResolved(style.backgroundColor, customPropMap);
|
// verbatim, and without this substitution the layer would read as
|
||||||
}
|
// unparseable and force a needless abstention.
|
||||||
if (!bg || bg.a < 0.1) {
|
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
|
||||||
// Inline-style fallback. jsdom doesn't decompose background
|
// The static cascade resolves var() text tokens before checks run, so
|
||||||
// shorthand, so colors set via inline style are otherwise invisible.
|
// style.color is normally already an rgb string here; parseColorResolved
|
||||||
const rawStyle = current.getAttribute?.('style') || '';
|
// is defense in depth for any future caller that passes a live
|
||||||
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
// customPropMap (it matches the text-color path in checkElementColors
|
||||||
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
|
// and reduces to parseAnyColor when the map is null or absent).
|
||||||
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
|
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
|
||||||
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bg && bg.a > 0.1) {
|
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);
|
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
|
// No solid bg-color at this level, but this level paints an image. CSS
|
||||||
// with no underlying solid color we can read:
|
// stacks background-image layers first-on-top, so which layer leads
|
||||||
// • on body/html: assume white. Body-level gradients are almost
|
// decides what the visitor sees:
|
||||||
// always decorative texture (paper grain, noise) on top of a
|
// • gradient on top — the gradient is the surface. Hand the caller a
|
||||||
// solid bg-color the page set via `background: var(--paper)`
|
// null color so it falls back to the gradient's own stops (body
|
||||||
// shorthand — which jsdom can't decompose into bg-color. The
|
// grounds, gradient buttons, hero sections).
|
||||||
// downstream gradient-stops fallback path produces catastrophic
|
// • url() on top — the surface is an image whose pixels this engine
|
||||||
// false positives in this case (gradient noise stops have
|
// cannot read, and it may fully cover every layer and ancestor
|
||||||
// accidental browns/blacks that look like card backgrounds).
|
// beneath it. Same contract as an unparseable color: abstain, so
|
||||||
// • on other elements: bail to null and let the caller fall back
|
// the gradient-stop fallback never measures a gradient the image
|
||||||
// to gradient stops (gradient buttons / hero sections are real
|
// hides (the shipped miss: `url(photo), linear-gradient(...)`
|
||||||
// bgs worth checking against).
|
// reported low-contrast against the invisible gradient's stops).
|
||||||
if (hasGradientOrUrl) {
|
if (hasGradientOrUrl) {
|
||||||
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
|
const layers = splitTopLevelCommas(bgImage);
|
||||||
return flatten({ r: 255, g: 255, b: 255, a: 1 });
|
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;
|
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.
|
// Walk parents looking for a gradient background and return its color stops.
|
||||||
// Used as a fallback when resolveBackground() returns null because the
|
// Used as a fallback when resolveBackground() returns null because the
|
||||||
// effective background is a gradient (no single solid color to compare against).
|
// 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) {
|
function resolveGradientStops(el, win, customPropMap) {
|
||||||
let current = el;
|
let current = el;
|
||||||
|
const overlays = [];
|
||||||
while (current && current.nodeType === 1) {
|
while (current && current.nodeType === 1) {
|
||||||
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
||||||
const bgImage = style.backgroundImage || '';
|
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;
|
let stops = null;
|
||||||
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
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);
|
const parsed = parseGradientColors(bgImage);
|
||||||
if (parsed.length > 0) stops = parsed;
|
if (parsed.length > 0) stops = parsed;
|
||||||
}
|
}
|
||||||
if (!stops && !DETECTOR_IS_BROWSER) {
|
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 rawStyle = current.getAttribute?.('style') || '';
|
||||||
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
||||||
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
||||||
@@ -1766,7 +1917,23 @@ function resolveGradientStops(el, win, customPropMap) {
|
|||||||
if (parsed.length > 0) stops = parsed;
|
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;
|
current = current.parentElement;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -1986,15 +2153,25 @@ function checkElementColorsDOM(el) {
|
|||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
if (rect.width < 10 || rect.height < 10) return [];
|
if (rect.width < 10 || rect.height < 10) return [];
|
||||||
const style = getComputedStyle(el);
|
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 directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||||
const hasDirectText = directText.trim().length > 0;
|
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);
|
let ownBg = readOwnBackgroundColor(el, style);
|
||||||
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
|
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
|
||||||
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
|
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
|
||||||
if (pseudoSurface) {
|
if (pseudoSurface) {
|
||||||
ownBg = pseudoSurface;
|
ownBg = pseudoSurface;
|
||||||
effectiveBg = pseudoSurface;
|
effectiveBg = pseudoSurface;
|
||||||
|
surfaceUnresolved = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return checkColors({
|
return checkColors({
|
||||||
@@ -2006,8 +2183,8 @@ function checkElementColorsDOM(el) {
|
|||||||
// an oklch token near its own oklch background).
|
// an oklch token near its own oklch background).
|
||||||
textColor: parseRgb(style.color) || parseAnyColor(style.color),
|
textColor: parseRgb(style.color) || parseAnyColor(style.color),
|
||||||
bgColor: ownBg,
|
bgColor: ownBg,
|
||||||
effectiveBg,
|
effectiveBg: surfaceUnresolved ? null : effectiveBg,
|
||||||
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
|
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
|
||||||
fontSize: parseFloat(style.fontSize) || 16,
|
fontSize: parseFloat(style.fontSize) || 16,
|
||||||
fontWeight: parseInt(style.fontWeight) || 400,
|
fontWeight: parseInt(style.fontWeight) || 400,
|
||||||
hasDirectText,
|
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.
|
// Resolve var() refs in a color string (via customPropMap), then parse.
|
||||||
// Returns null on any failure. Used in jsdom-mode paths where
|
// Returns null on any failure. Used in jsdom-mode paths where
|
||||||
@@ -2796,9 +2696,20 @@ function checkElementGlowDOM(el) {
|
|||||||
if (!boxShadow && !textShadow) return [];
|
if (!boxShadow && !textShadow) return [];
|
||||||
// Use parent's background — glow radiates outward, so the surrounding context matters
|
// Use parent's background — glow radiates outward, so the surrounding context matters
|
||||||
// If resolveBackground returns null (gradient), try to infer from the gradient colors
|
// If resolveBackground returns null (gradient), try to infer from the gradient colors
|
||||||
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
|
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
|
||||||
if (!parentBg) {
|
// Unknown surface (an unreadable layer on the way up): skip only the
|
||||||
// Gradient background — sample its colors to determine if it's dark
|
// 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;
|
let cur = el.parentElement;
|
||||||
while (cur && cur.nodeType === 1) {
|
while (cur && cur.nodeType === 1) {
|
||||||
const bgImage = getComputedStyle(cur).backgroundImage || '';
|
const bgImage = getComputedStyle(cur).backgroundImage || '';
|
||||||
@@ -2846,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
|
|||||||
const hue = getHue(textColor);
|
const hue = getHue(textColor);
|
||||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||||
if (isAIPalette) {
|
if (isAIPalette) {
|
||||||
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
|
const parentBgInfo = el.parentElement
|
||||||
// Also check gradient parents
|
? resolveBackgroundInfo(el.parentElement)
|
||||||
let effectiveBg = parentBg;
|
: { color: null, unresolved: false };
|
||||||
if (!effectiveBg) {
|
// 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;
|
let cur = el.parentElement;
|
||||||
while (cur && cur.nodeType === 1) {
|
while (cur && cur.nodeType === 1) {
|
||||||
const gi = getComputedStyle(cur).backgroundImage || '';
|
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) {
|
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 directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||||
const hasDirectText = directText.trim().length > 0;
|
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
|
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
|
||||||
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
|
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
|
||||||
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
|
// 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
|
// element itself has no usable own background, that pseudo is the real
|
||||||
// surface for contrast purposes.
|
// surface for contrast purposes.
|
||||||
let finalEffectiveBg = effectiveBg;
|
let finalEffectiveBg = effectiveBg;
|
||||||
|
let surfaceUnresolved = bgInfo.unresolved;
|
||||||
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
|
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
|
||||||
const pseudoSurface = window.getPseudoSurface(el);
|
const pseudoSurface = window.getPseudoSurface(el);
|
||||||
if (pseudoSurface) {
|
if (pseudoSurface) {
|
||||||
ownBg = pseudoSurface;
|
ownBg = pseudoSurface;
|
||||||
finalEffectiveBg = pseudoSurface;
|
finalEffectiveBg = pseudoSurface;
|
||||||
|
surfaceUnresolved = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3705,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
|||||||
tag,
|
tag,
|
||||||
textColor,
|
textColor,
|
||||||
bgColor: ownBg,
|
bgColor: ownBg,
|
||||||
effectiveBg: finalEffectiveBg,
|
// Unknown surface: hand the checks nothing rather than a guess.
|
||||||
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
|
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
|
||||||
|
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
|
||||||
fontSize: parseFloat(style.fontSize) || 16,
|
fontSize: parseFloat(style.fontSize) || 16,
|
||||||
fontWeight: parseInt(style.fontWeight) || 400,
|
fontWeight: parseInt(style.fontWeight) || 400,
|
||||||
hasDirectText,
|
hasDirectText,
|
||||||
@@ -4802,6 +4728,11 @@ function isRenderedForBrowserRule(el) {
|
|||||||
function checkElementTextOverflowDOM(el) {
|
function checkElementTextOverflowDOM(el) {
|
||||||
const tag = el.tagName.toLowerCase();
|
const tag = el.tagName.toLowerCase();
|
||||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
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 [];
|
if (!isRenderedForBrowserRule(el)) return [];
|
||||||
// Only the element that actually owns overflowing text — not its ancestors,
|
// Only the element that actually owns overflowing text — not its ancestors,
|
||||||
// which inherit a wider scrollWidth from the spilling descendant.
|
// 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.
|
// path is pure geometry and runs anywhere on the page.
|
||||||
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
|
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() {
|
function checkTextOcclusionDOM() {
|
||||||
const findings = [];
|
const findings = [];
|
||||||
const seenVictims = new Set();
|
const seenVictims = new Set();
|
||||||
@@ -5213,6 +5160,11 @@ function checkTextOcclusionDOM() {
|
|||||||
}
|
}
|
||||||
return false;
|
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
|
// Collect renderable text owners in / near the first viewport for the
|
||||||
// elementFromPoint probe. SVG <text> counts too.
|
// elementFromPoint probe. SVG <text> counts too.
|
||||||
@@ -5225,6 +5177,7 @@ function checkTextOcclusionDOM() {
|
|||||||
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
|
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
|
||||||
if (text.length < 2) continue;
|
if (text.length < 2) continue;
|
||||||
if (!isPaintedForOcclusion(el)) continue;
|
if (!isPaintedForOcclusion(el)) continue;
|
||||||
|
if (effectiveOpacity(el) <= 0.02) continue;
|
||||||
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
|
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
|
||||||
if (rect.width < 6 || rect.height < 6) continue;
|
if (rect.width < 6 || rect.height < 6) continue;
|
||||||
// Viewport-bound probe: keep text whose box overlaps the live viewport.
|
// 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;
|
if (top === el || el.contains(top) || top.contains(el)) continue;
|
||||||
const topCs = getComputedStyle(top);
|
const topCs = getComputedStyle(top);
|
||||||
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
|
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
|
||||||
|
if (effectiveOpacity(top) <= 0.02) continue;
|
||||||
const topTag = top.tagName.toLowerCase();
|
const topTag = top.tagName.toLowerCase();
|
||||||
// Text sitting under a raw image/video is contrast territory (deduped
|
// Text sitting under a raw image/video is contrast territory (deduped
|
||||||
// against the pixel low-contrast rule); leave those alone here.
|
// against the pixel low-contrast rule); leave those alone here.
|
||||||
@@ -5468,6 +5422,7 @@ export {
|
|||||||
CSS_NAMED_COLORS,
|
CSS_NAMED_COLORS,
|
||||||
checkBorders,
|
checkBorders,
|
||||||
isEmojiOnlyText,
|
isEmojiOnlyText,
|
||||||
|
scopedIgnoreActive,
|
||||||
checkColors,
|
checkColors,
|
||||||
checkHoverContrast,
|
checkHoverContrast,
|
||||||
checkElementHoverContrast,
|
checkElementHoverContrast,
|
||||||
@@ -5497,6 +5452,7 @@ export {
|
|||||||
checkHtmlPatterns,
|
checkHtmlPatterns,
|
||||||
readOwnBackgroundColor,
|
readOwnBackgroundColor,
|
||||||
resolveBackground,
|
resolveBackground,
|
||||||
|
resolveBackgroundInfo,
|
||||||
resolveGradientStops,
|
resolveGradientStops,
|
||||||
parseRadiusToPx,
|
parseRadiusToPx,
|
||||||
resolveBorderRadiusPx,
|
resolveBorderRadiusPx,
|
||||||
|
|||||||
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
|
|||||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
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) {
|
function parseGradientColors(bgImage) {
|
||||||
if (!bgImage || !bgImage.includes('gradient')) return [];
|
if (!bgImage || !bgImage.includes('gradient')) return [];
|
||||||
const colors = [];
|
const colors = [];
|
||||||
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
|
// Stops arrive in whatever syntax the author wrote and the browser kept.
|
||||||
const c = parseRgb(m[0]);
|
// 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);
|
if (c) colors.push(c);
|
||||||
}
|
}
|
||||||
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
|
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('');
|
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 {
|
export {
|
||||||
isNeutralColor,
|
isNeutralColor,
|
||||||
parseRgb,
|
parseRgb,
|
||||||
relativeLuminance,
|
relativeLuminance,
|
||||||
contrastRatio,
|
contrastRatio,
|
||||||
parseGradientColors,
|
parseGradientColors,
|
||||||
|
extractColorFunctionTokens,
|
||||||
hasChroma,
|
hasChroma,
|
||||||
getHue,
|
getHue,
|
||||||
colorToHex,
|
colorToHex,
|
||||||
|
oklabToRgb,
|
||||||
|
oklchToRgb,
|
||||||
|
labToRgb,
|
||||||
|
lchToRgb,
|
||||||
|
colorFunctionToRgb,
|
||||||
|
hslToRgb,
|
||||||
|
hwbToRgb,
|
||||||
|
CSS_NAMED_COLORS,
|
||||||
|
splitTopLevelCommas,
|
||||||
|
parseColorMix,
|
||||||
|
parseAnyColor,
|
||||||
|
compositeColorOver,
|
||||||
|
isNoPaintColorValue,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
stampProductSchema,
|
stampProductSchema,
|
||||||
} from './lib/artifact-schema.mjs';
|
} from './lib/artifact-schema.mjs';
|
||||||
import {
|
import {
|
||||||
|
checkBuildPathUnset,
|
||||||
checkConfig,
|
checkConfig,
|
||||||
checkDesignSidecar,
|
checkDesignSidecar,
|
||||||
checkNativePlatformEvidence,
|
checkNativePlatformEvidence,
|
||||||
@@ -120,6 +121,7 @@ async function collect(cwd, targetOptions) {
|
|||||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||||
|
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||||
...checkHookInstallation({
|
...checkHookInstallation({
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
*
|
*
|
||||||
* node generate-image.mjs --prompt "..." --out mock.png [--size 1536x1024] [--quality medium]
|
* node generate-image.mjs --prompt "..." --out mock.png [--size 1536x1024] [--quality medium]
|
||||||
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
|
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
|
||||||
|
* node generate-image.mjs --prompt "..." --out mock.png --ref screenshot.png [--ref more.png]
|
||||||
|
*
|
||||||
|
* --ref anchors generation on input image(s) via the edits endpoint: pass a
|
||||||
|
* captured screenshot of a representative existing page when comping a new
|
||||||
|
* surface for an established world, so the identity comes from the real UI.
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import zlib from 'node:zlib';
|
import zlib from 'node:zlib';
|
||||||
@@ -212,12 +217,44 @@ if (!prompt || !out) {
|
|||||||
}
|
}
|
||||||
const size = arg('size', '1536x1024');
|
const size = arg('size', '1536x1024');
|
||||||
const quality = arg('quality', 'medium');
|
const quality = arg('quality', 'medium');
|
||||||
|
// Reference images (--ref, repeatable): route through the edits endpoint,
|
||||||
|
// which accepts input images. This is how a comp for an established world
|
||||||
|
// inherits the real UI's identity from a captured screenshot instead of a
|
||||||
|
// prose paraphrase of it; the prompt then describes the NEW surface and the
|
||||||
|
// reference carries palette, type, and component character.
|
||||||
|
const refs = (() => {
|
||||||
|
const found = [];
|
||||||
|
for (let i = 0; i < process.argv.length; i += 1) {
|
||||||
|
if (process.argv[i] === '--ref' && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) found.push(process.argv[i + 1]);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
})();
|
||||||
|
|
||||||
const response = await fetch('https://api.openai.com/v1/images/generations', {
|
let response;
|
||||||
method: 'POST',
|
if (refs.length) {
|
||||||
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
const form = new FormData();
|
||||||
body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
|
form.append('model', 'gpt-image-2');
|
||||||
});
|
form.append('prompt', prompt);
|
||||||
|
form.append('size', size);
|
||||||
|
form.append('quality', quality);
|
||||||
|
form.append('n', '1');
|
||||||
|
for (const ref of refs) {
|
||||||
|
const bytes = fs.readFileSync(ref);
|
||||||
|
const type = ref.endsWith('.png') ? 'image/png' : ref.endsWith('.webp') ? 'image/webp' : 'image/jpeg';
|
||||||
|
form.append('image[]', new Blob([bytes], { type }), ref.split('/').pop());
|
||||||
|
}
|
||||||
|
response = await fetch('https://api.openai.com/v1/images/edits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${key}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch('https://api.openai.com/v1/images/generations', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error(`generate-image: API error ${response.status}: ${(await response.text()).slice(0, 300)}`);
|
console.error(`generate-image: API error ${response.status}: ${(await response.text()).slice(0, 300)}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -235,6 +272,6 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
|
|||||||
try {
|
try {
|
||||||
const { spawnSync } = await import('node:child_process');
|
const { spawnSync } = await import('node:child_process');
|
||||||
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
||||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2' }, null, 2));
|
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
|
||||||
} catch { /* embedding is best-effort */ }
|
} catch { /* embedding is best-effort */ }
|
||||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ALLOWED_EXTS,
|
ALLOWED_EXTS,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
EDIT_COUNT_THRESHOLD,
|
EDIT_COUNT_THRESHOLD,
|
||||||
GENERATED_PATH,
|
GENERATED_PATH,
|
||||||
SENSITIVE_PATH,
|
SENSITIVE_PATH,
|
||||||
appendDesignSystemNote,
|
appendDesignSystemNoteOnce,
|
||||||
|
commitFooterShown,
|
||||||
|
designNoteReserve,
|
||||||
designSystemOptions,
|
designSystemOptions,
|
||||||
|
footerModeForSession,
|
||||||
filterFindings,
|
filterFindings,
|
||||||
isNativePlatform,
|
isNativePlatform,
|
||||||
isScanTargetInsideProject,
|
isScanTargetInsideProject,
|
||||||
@@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
// Cursor caps deny messages around 4000 chars. The cap feeds through the
|
||||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
// renderer's clamp, which preserves the policy footer, rather than tail-
|
||||||
const blocked = rendered.replace(
|
// 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] 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) {
|
function findingSignature(findings) {
|
||||||
@@ -468,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 sessionId = event.session_id || event.conversation_id || 'unknown';
|
||||||
const cache = readCache(cwd);
|
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);
|
const denial = bumpCursorDenial(cache, sessionId, filePath, filtered);
|
||||||
persistCache(cwd, cache);
|
persistCache(cwd, cache);
|
||||||
if (denial.count > EDIT_COUNT_THRESHOLD) {
|
if (denial.count > EDIT_COUNT_THRESHOLD) {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
||||||
* renderTemplate(findings, filePath, config, opts)
|
* renderTemplate(findings, filePath, config, opts)
|
||||||
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, 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?)
|
* shouldEmitAckForFile(filePath, config?)
|
||||||
* writeAuditLog(env, entry)
|
* writeAuditLog(env, entry)
|
||||||
* loadDetector() -> Promise<{ detectText, detectHtml }>
|
* loadDetector() -> Promise<{ detectText, detectHtml }>
|
||||||
@@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
|||||||
if (!Array.isArray(findings) || findings.length === 0) return '';
|
if (!Array.isArray(findings) || findings.length === 0) return '';
|
||||||
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
||||||
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
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 cwd = opts.cwd || process.cwd();
|
||||||
const display = relativize(filePath, cwd);
|
const display = relativize(filePath, cwd);
|
||||||
@@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
|||||||
const remaining = total - shown.length;
|
const remaining = total - shown.length;
|
||||||
|
|
||||||
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
|
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
|
const more = remaining > 0
|
||||||
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
|
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
|
||||||
: null;
|
: null;
|
||||||
const footer = directiveFooter(display);
|
const footer = directiveFooter({ mode: opts.footer });
|
||||||
|
|
||||||
const blocks = [header, ...lines];
|
const blocks = [header, ...lines];
|
||||||
if (more) blocks.push(more);
|
if (more) blocks.push(more);
|
||||||
@@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
|||||||
|
|
||||||
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
||||||
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
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 cwd = opts.cwd || process.cwd();
|
||||||
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
|
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 header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
|
||||||
const lines = [];
|
const lines = [];
|
||||||
let shownCount = 0;
|
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) {
|
for (const group of realGroups) {
|
||||||
const display = relativize(group.filePath, cwd);
|
const display = relativize(group.filePath, cwd);
|
||||||
@@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
|||||||
const remainingCap = Math.max(0, cap - shownCount);
|
const remainingCap = Math.max(0, cap - shownCount);
|
||||||
const shown = group.findings.slice(0, remainingCap);
|
const shown = group.findings.slice(0, remainingCap);
|
||||||
for (const finding of shown) {
|
for (const finding of shown) {
|
||||||
lines.push(formatFindingLine(finding));
|
lines.push(formatDedupedFindingLine(finding, seenRules));
|
||||||
}
|
}
|
||||||
shownCount += shown.length;
|
shownCount += shown.length;
|
||||||
const hidden = group.findings.length - 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');
|
let text = [header, ...lines, '', footer].join('\n');
|
||||||
if (text.length > maxChars) {
|
if (text.length > maxChars) {
|
||||||
text = clampGroupedToBudget(header, lines, footer, maxChars);
|
text = clampGroupedToBudget(header, lines, footer, maxChars);
|
||||||
@@ -1037,82 +1050,149 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
|||||||
return text;
|
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) {
|
function clampGroupedToBudget(header, lines, footer, maxChars) {
|
||||||
const assemble = (linesArr, omitted) => [
|
const assemble = (linesArr, omitted, footerText) => [
|
||||||
header,
|
header,
|
||||||
...linesArr,
|
...linesArr,
|
||||||
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
|
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
|
||||||
'',
|
'',
|
||||||
footer,
|
footerText,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
let working = lines.slice();
|
for (const footerText of footerFallbacks(footer)) {
|
||||||
let omitted = false;
|
let working = lines.slice();
|
||||||
let assembled = assemble(working, omitted);
|
let omitted = false;
|
||||||
while (assembled.length > maxChars && working.length > 1) {
|
let assembled = assemble(working, omitted, footerText);
|
||||||
working.pop();
|
while (assembled.length > maxChars && working.length > 1) {
|
||||||
omitted = true;
|
working.pop();
|
||||||
assembled = assemble(working, omitted);
|
omitted = true;
|
||||||
|
assembled = assemble(working, omitted, footerText);
|
||||||
|
}
|
||||||
|
if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled;
|
||||||
}
|
}
|
||||||
if (assembled.length > maxChars) {
|
return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText),
|
||||||
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
lines.find(isFindingLine) || lines[0], maxChars);
|
||||||
}
|
|
||||||
return assembled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampToBudget(header, lines, more, footer, maxChars) {
|
function clampToBudget(header, lines, more, footer, maxChars) {
|
||||||
const assemble = (linesArr, moreText) => {
|
const assemble = (linesArr, moreText, footerText) => {
|
||||||
const blocks = [header, ...linesArr];
|
const blocks = [header, ...linesArr];
|
||||||
if (moreText) blocks.push(moreText);
|
if (moreText) blocks.push(moreText);
|
||||||
blocks.push('');
|
blocks.push('');
|
||||||
blocks.push(footer);
|
blocks.push(footerText);
|
||||||
return blocks.join('\n');
|
return blocks.join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
let working = lines.slice();
|
let lastMore = more;
|
||||||
let moreText = more;
|
for (const footerText of footerFallbacks(footer)) {
|
||||||
let assembled = assemble(working, moreText);
|
let working = lines.slice();
|
||||||
while (assembled.length > maxChars && working.length > 1) {
|
let moreText = more;
|
||||||
working.pop();
|
let assembled = assemble(working, moreText, footerText);
|
||||||
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
|
while (assembled.length > maxChars && working.length > 1) {
|
||||||
assembled = assemble(working, moreText);
|
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) {
|
return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText),
|
||||||
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
lines.find(isFindingLine) || lines[0], maxChars);
|
||||||
}
|
|
||||||
return assembled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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();
|
const name = (f.name || '').trim();
|
||||||
// Description from the registry already ends in punctuation; join with a
|
// Description from the registry already ends in punctuation; join with a
|
||||||
// single space. `name` may have a trailing period already, keep it clean.
|
// single space. `name` may have a trailing period already, keep it clean.
|
||||||
const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
|
const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
|
||||||
const ignoreCommand = formatFindingIgnoreCommand(f);
|
const ignoreHint = formatFindingIgnoreHint(f);
|
||||||
const ignoreSegment = ignoreCommand
|
const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : '';
|
||||||
? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`
|
|
||||||
: '';
|
|
||||||
return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
|
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 '';
|
if (!finding || typeof finding !== 'object') return '';
|
||||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||||
if (!rule) return '';
|
if (!rule) return '';
|
||||||
const normalizedValue = extractFindingIgnoreValue(finding);
|
const normalizedValue = extractFindingIgnoreValue(finding);
|
||||||
if (!normalizedValue) return '';
|
if (!normalizedValue) return '';
|
||||||
const value = extractFindingIgnoreValueRaw(finding);
|
const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding));
|
||||||
const valueArg = quoteCommandArg(value);
|
return `ignore-value ${rule} ${valueArg}`;
|
||||||
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
|
|
||||||
return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function quoteCommandArg(value) {
|
function quoteCommandArg(value) {
|
||||||
const text = String(value || '').trim();
|
const text = String(value || '').trim();
|
||||||
if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
|
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) {
|
function relativize(filePath, cwd) {
|
||||||
@@ -1594,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) {
|
export function appendDesignSystemNote(text, scanOptions) {
|
||||||
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
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
|
// The directive footer is the part of the hook output that steers model
|
||||||
// behavior. Three intentional moves:
|
// behavior. Intentional moves, in order:
|
||||||
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
|
// 1. **Imperative, not advisory.** "Triage each finding..." beats
|
||||||
// revising..." which the model treats as a soft suggestion it can
|
// "Consider revising...", which the model treats as a soft suggestion.
|
||||||
// override when the user asked for any kind of throwaway / demo UI.
|
// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The
|
||||||
// 2. **Explicit judgment clause.** Without it, the model will try to
|
// suppress branch names the calibration examples (demo, fixture,
|
||||||
// "fix" intentional motion, bad fixtures, anti-pattern examples in
|
// documented bad design, user-confirmed choice) because the agent now
|
||||||
// docs, or test cases. Naming the judgment inline beats hoping the
|
// acts on its own confidence and needs the bar stated.
|
||||||
// model infers it from context.
|
// 3. **Executable ignore path.** The old footer named only the slash
|
||||||
// 3. **Acknowledgement instruction.** Hook output is injected as
|
// command, which an agent reacting to hook output cannot run; the
|
||||||
// developer-role context, not a chat turn, so the user never sees the
|
// hook-admin.mjs invocation is runnable as-is and keeps agents out of
|
||||||
// raw envelope. Asking the model to surface the resolution in its
|
// hand-editing config.json.
|
||||||
// reply is the cheapest way to make the feedback loop visible.
|
// 4. **Honest provenance.** The --reason is the audit trail; "user
|
||||||
function directiveFooter(display, opts = {}) {
|
// confirmed" appears only when the user actually did.
|
||||||
// Offer the rule-scoped-to-file form first. `ignore-file` silences every rule
|
// 5. **Acknowledgement instruction.** Hook output is injected as
|
||||||
// for the path forever, which is far more than one noisy rule on a real UI
|
// developer-role context, so the reply is where the user sees the
|
||||||
// surface justifies, and it was previously the only option named here.
|
// resolution, including any ignore the agent persisted.
|
||||||
const target = opts.grouped ? '<path>' : quoteCommandArg(display);
|
// 6. **Once per session.** The full policy emits on the session's first
|
||||||
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)`;
|
// 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 [
|
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.',
|
'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.',
|
||||||
'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.',
|
`- 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.',
|
||||||
`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.`,
|
`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');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1845,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist only when the write is earned: fresh findings justify creating
|
// The session notice flags mutate the cache, so they must settle before
|
||||||
// `.impeccable/` (dedup and suppression need it), deferred findings do
|
// the persist that makes them stick across events.
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (freshGroups.length > 0) {
|
if (freshGroups.length > 0) {
|
||||||
const firstGroup = freshGroups[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);
|
const allFindings = freshGroups.flatMap((group) => group.findings);
|
||||||
return {
|
return {
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
@@ -1881,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) {
|
if (detectorThrewAny && !pendingWinner && !cleanWinner) {
|
||||||
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
|
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
|
||||||
}
|
}
|
||||||
@@ -1889,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|||||||
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
|
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
|
if (ack?.kind === 'pending') {
|
||||||
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
|
const text = ack.text;
|
||||||
return {
|
return {
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
stdout: payload(text, 'PostToolUse', harness),
|
stdout: payload(text, 'PostToolUse', harness),
|
||||||
@@ -1923,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
|
if (ack?.kind === 'clean') {
|
||||||
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
|
const text = ack.text;
|
||||||
return {
|
return {
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
stdout: payload(text, 'PostToolUse', harness),
|
stdout: payload(text, 'PostToolUse', harness),
|
||||||
@@ -2108,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
|||||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
// A per-edit fire earlier in this session already consumed the footer
|
||||||
// unless new issues appear.
|
// flag, so the Stop wall of text carries the one-line short footer.
|
||||||
persistCache(projectCwd, cache);
|
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 {
|
return {
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
stdout: payload(text, 'Stop', harness),
|
stdout: payload(text, 'Stop', harness),
|
||||||
|
|||||||
@@ -206,10 +206,10 @@ function parseIgnoreColor(value) {
|
|||||||
if (rgb) {
|
if (rgb) {
|
||||||
const parts = splitColorArgs(rgb[1]);
|
const parts = splitColorArgs(rgb[1]);
|
||||||
if (parts.length < 3 || parts.length > 4) return null;
|
if (parts.length < 3 || parts.length > 4) return null;
|
||||||
const r = parseRgbChannel(parts[0]);
|
const r = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.rgb);
|
||||||
const g = parseRgbChannel(parts[1]);
|
const g = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.rgb);
|
||||||
const b = parseRgbChannel(parts[2]);
|
const b = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.rgb);
|
||||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
|
||||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||||
return { r, g, b, a };
|
return { r, g, b, a };
|
||||||
}
|
}
|
||||||
@@ -218,10 +218,10 @@ function parseIgnoreColor(value) {
|
|||||||
if (hsl) {
|
if (hsl) {
|
||||||
const parts = splitColorArgs(hsl[1]);
|
const parts = splitColorArgs(hsl[1]);
|
||||||
if (parts.length < 3 || parts.length > 4) return null;
|
if (parts.length < 3 || parts.length > 4) return null;
|
||||||
const h = parseHueChannel(parts[0]);
|
const h = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.hue);
|
||||||
const s = parsePercentChannel(parts[1]);
|
const s = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.percent);
|
||||||
const l = parsePercentChannel(parts[2]);
|
const l = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.percent);
|
||||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
|
||||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||||
return hslToRgb(h, s, l, a);
|
return hslToRgb(h, s, l, a);
|
||||||
}
|
}
|
||||||
@@ -230,18 +230,13 @@ function parseIgnoreColor(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseHexIgnoreColor(hex) {
|
function parseHexIgnoreColor(hex) {
|
||||||
if (hex.length === 3 || hex.length === 4) {
|
const expanded = hex.length <= 4
|
||||||
const r = parseInt(hex[0] + hex[0], 16);
|
? [...hex].map((digit) => digit.repeat(2)).join('')
|
||||||
const g = parseInt(hex[1] + hex[1], 16);
|
: hex;
|
||||||
const b = parseInt(hex[2] + hex[2], 16);
|
const [r, g, b, alpha = 255] = expanded
|
||||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
.match(/../g)
|
||||||
return { r, g, b, a };
|
.map((channel) => Number.parseInt(channel, 16));
|
||||||
}
|
return { r, g, b, a: alpha / 255 };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitColorArgs(body) {
|
function splitColorArgs(body) {
|
||||||
@@ -259,47 +254,34 @@ function splitColorArgs(body) {
|
|||||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRgbChannel(raw) {
|
const CSS_NUMBER_RE = /^(-?\d*\.?\d+)(%|deg|rad|turn|grad)?$/;
|
||||||
const text = String(raw || '').trim();
|
const identity = (value) => value;
|
||||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
const COLOR_CHANNEL_FORMATS = {
|
||||||
if (!match) return null;
|
rgb: { units: { '': identity, '%': (value) => value * 2.55 }, min: 0, max: 255, round: true },
|
||||||
const value = Number.parseFloat(match[1]);
|
alpha: { units: { '': identity, '%': (value) => value / 100 }, min: 0, max: 1 },
|
||||||
if (!Number.isFinite(value)) return null;
|
hue: {
|
||||||
const scaled = match[2] ? value * 2.55 : value;
|
units: {
|
||||||
if (scaled < 0 || scaled > 255) return null;
|
'': identity,
|
||||||
return Math.round(scaled);
|
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 text = String(raw || '').trim();
|
||||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
const match = text.match(CSS_NUMBER_RE);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const value = Number.parseFloat(match[1]);
|
const convert = units[match[2] || ''];
|
||||||
if (!Number.isFinite(value)) return null;
|
if (!convert) return null;
|
||||||
const alpha = match[2] ? value / 100 : value;
|
const number = Number.parseFloat(match[1]);
|
||||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
if (!Number.isFinite(number)) return null;
|
||||||
}
|
const value = convert(number);
|
||||||
|
if (value < min || value > max) return null;
|
||||||
function parseHueChannel(raw) {
|
return round ? Math.round(value) : value;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* within the first ~300 characters — catches non-git projects.
|
* 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 fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
@@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) {
|
|||||||
|
|
||||||
function isGitIgnored(absPath, cwd) {
|
function isGitIgnored(absPath, cwd) {
|
||||||
try {
|
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,
|
cwd,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/;
|
|||||||
// * bundle-relative: node ".agents/.../hook.mjs"
|
// * bundle-relative: node ".agents/.../hook.mjs"
|
||||||
// * legacy unquoted: node .claude/.../hook.mjs
|
// * legacy unquoted: node .claude/.../hook.mjs
|
||||||
// * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical)
|
// * 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"
|
// * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs"
|
||||||
// A quoted path wins; the guard's two occurrences are identical, so the first
|
// 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-
|
// quoted match is the path. Otherwise fall back to the whitespace/metachar-
|
||||||
@@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) {
|
|||||||
if (!HOOK_MARKER.test(str)) return null;
|
if (!HOOK_MARKER.test(str)) return null;
|
||||||
const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/);
|
const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/);
|
||||||
if (quoted) return quoted[1];
|
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)/);
|
const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/);
|
||||||
return bare ? bare[1] : null;
|
return bare ? bare[1] : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,18 +47,33 @@ import {
|
|||||||
|
|
||||||
// Top-level keys any reader honors: `hook` and `detector` subtrees (hook-lib's
|
// Top-level keys any reader honors: `hook` and `detector` subtrees (hook-lib's
|
||||||
// readConfig), `updateCheck` (context.mjs), `projectRoots` (context.mjs's
|
// readConfig), `updateCheck` (context.mjs), `projectRoots` (context.mjs's
|
||||||
// monorepo resolution), plus `stalenessCheck` below. `$schema` and `version`
|
// monorepo resolution), `buildPath` (context.mjs's build-path directive), plus
|
||||||
// are allowed as conventional metadata nobody reads.
|
// `stalenessCheck` below. `$schema` and `version` are allowed as conventional
|
||||||
|
// metadata nobody reads.
|
||||||
const KNOWN_CONFIG_KEYS = new Set([
|
const KNOWN_CONFIG_KEYS = new Set([
|
||||||
'hook',
|
'hook',
|
||||||
'detector',
|
'detector',
|
||||||
'updateCheck',
|
'updateCheck',
|
||||||
'stalenessCheck',
|
'stalenessCheck',
|
||||||
'projectRoots',
|
'projectRoots',
|
||||||
|
'buildPath',
|
||||||
'$schema',
|
'$schema',
|
||||||
'version',
|
'version',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// The only two values context.mjs and new-work honor. A near miss reads as a
|
||||||
|
// working preference and silently rides the opposite path, so it is worth
|
||||||
|
// reporting rather than coercing.
|
||||||
|
const BUILD_PATH_VALUES = Object.freeze(['comp', 'code']);
|
||||||
|
|
||||||
|
// Evidence that this project does the kind of work `buildPath` governs. A
|
||||||
|
// project that only ever ran polish or audit has no use for the setting and
|
||||||
|
// should never be told it exists. Two stats, so Tier 1 can afford it.
|
||||||
|
const DIRECTION_WORK_PATHS = Object.freeze([
|
||||||
|
path.join('.impeccable', 'surfaces'),
|
||||||
|
path.join('.impeccable', 'mocks', 'decision'),
|
||||||
|
]);
|
||||||
|
|
||||||
// `detector` is a closed set, so a typo here is worth reporting. `hook` is not
|
// `detector` is a closed set, so a typo here is worth reporting. `hook` is not
|
||||||
// checked: it carries runtime settings from several writers and the false
|
// checked: it carries runtime settings from several writers and the false
|
||||||
// positive rate would outweigh the catch.
|
// positive rate would outweigh the catch.
|
||||||
@@ -325,6 +340,20 @@ export function checkConfig({ projectRoot, repoRoot }) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(raw, 'buildPath')
|
||||||
|
&& !BUILD_PATH_VALUES.includes(raw.buildPath)) {
|
||||||
|
findings.push(finding({
|
||||||
|
id: 'config-invalid-build-path',
|
||||||
|
artifact: 'config.json',
|
||||||
|
filePath: rel,
|
||||||
|
severity: 'mention',
|
||||||
|
summary: `${rel} sets \`buildPath\` to ${JSON.stringify(raw.buildPath)}, which nothing reads. `
|
||||||
|
+ `The values are ${BUILD_PATH_VALUES.map((value) => `\`${value}\``).join(' and ')}.`,
|
||||||
|
fix: 'Report the value. An unread `buildPath` does not fall back to the other path; '
|
||||||
|
+ 'it falls back to the default, so a project meaning `code` has been building comp-led.',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const detector = raw.detector;
|
const detector = raw.detector;
|
||||||
if (detector && typeof detector === 'object' && !Array.isArray(detector)) {
|
if (detector && typeof detector === 'object' && !Array.isArray(detector)) {
|
||||||
const unknownDetector = Object.keys(detector).filter((key) => !KNOWN_DETECTOR_KEYS.has(key));
|
const unknownDetector = Object.keys(detector).filter((key) => !KNOWN_DETECTOR_KEYS.has(key));
|
||||||
@@ -345,6 +374,47 @@ export function checkConfig({ projectRoot, repoRoot }) {
|
|||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No recorded build-path preference on a project that plainly does visual
|
||||||
|
* direction work. Not drift in the usual sense: the setting is newer than the
|
||||||
|
* project, so every project that predates it lands here at once. That is why
|
||||||
|
* it is gated twice, on a product record and on evidence of the work the
|
||||||
|
* setting governs, and why it says the choice rather than assuming a harness
|
||||||
|
* can make it. Image generation is the real precondition and this module
|
||||||
|
* cannot see it: a harness-native image tool leaves no trace on disk, so the
|
||||||
|
* finding hands the question to the one reader that knows.
|
||||||
|
*/
|
||||||
|
export function checkBuildPathUnset({ projectRoot, repoRoot, product }) {
|
||||||
|
if (!projectRoot || !product) return [];
|
||||||
|
const roots = [...new Set([projectRoot, repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
for (const name of ['config.json', 'config.local.json']) {
|
||||||
|
const raw = readJson(path.join(root, '.impeccable', name));
|
||||||
|
// Any declared value ends this, valid or not: an invalid one already has
|
||||||
|
// its own finding and two reports of one key is noise.
|
||||||
|
if (raw && Object.prototype.hasOwnProperty.call(raw, 'buildPath')) return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const evidence = DIRECTION_WORK_PATHS.filter((rel) => fs.existsSync(path.join(projectRoot, rel)));
|
||||||
|
if (!evidence.length) return [];
|
||||||
|
|
||||||
|
return [finding({
|
||||||
|
id: 'config-build-path-unset',
|
||||||
|
artifact: 'config.json',
|
||||||
|
filePath: '.impeccable/config.json',
|
||||||
|
severity: 'mention',
|
||||||
|
summary: 'This project has run visual direction work but records no `buildPath`, '
|
||||||
|
+ 'so every direction round takes the comp-first default without anyone having chosen it.',
|
||||||
|
fix: 'Only when image generation exists in your tool surface, offer the choice once: '
|
||||||
|
+ '**comp-first** (an image sets the bar before any code; bolder composition, slower) or '
|
||||||
|
+ '**code-first** (build directly; ambition carried by the direction contract; leaner, faster). '
|
||||||
|
+ 'Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, '
|
||||||
|
+ 'merging with the keys already there. Without image generation there is no choice to record: stay silent.',
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Surface briefs ────────────────────────────────────────────────────────
|
// ─── Surface briefs ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -446,6 +516,7 @@ export function collectBootFindings(ctx, extras = {}) {
|
|||||||
projectRoot,
|
projectRoot,
|
||||||
}),
|
}),
|
||||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||||
|
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||||
...(extras.projectRootPatterns
|
...(extras.projectRootPatterns
|
||||||
? checkProjectRoots({
|
? checkProjectRoots({
|
||||||
|
|||||||
@@ -170,51 +170,35 @@ Output (JSON):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (svelteComponentManifest) {
|
if (svelteComponentManifest) {
|
||||||
if (isDiscard) {
|
const { sourceFile, componentDir } = svelteComponentManifest;
|
||||||
let result;
|
const resultContext = {
|
||||||
try {
|
file: sourceFile,
|
||||||
result = withSourceLockSync(
|
...(isDiscard ? { carbonize: false } : { sourceFile }),
|
||||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
previewMode: 'svelte-component',
|
||||||
'discard:' + id,
|
componentDir,
|
||||||
() => {
|
};
|
||||||
removeSvelteComponentSession(id, process.cwd());
|
const runOperation = isDiscard
|
||||||
return { handled: true };
|
? () => {
|
||||||
},
|
removeSvelteComponentSession(id, process.cwd());
|
||||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
return { handled: true, ...resultContext };
|
||||||
);
|
}
|
||||||
} catch (err) {
|
: () => inlineSvelteComponentAccept(
|
||||||
result = operationFailure(err);
|
|
||||||
}
|
|
||||||
emitResult({
|
|
||||||
...result,
|
|
||||||
file: svelteComponentManifest.sourceFile,
|
|
||||||
carbonize: false,
|
|
||||||
previewMode: 'svelte-component',
|
|
||||||
componentDir: svelteComponentManifest.componentDir,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = withSourceLockSync(
|
|
||||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
|
||||||
'accept:' + id,
|
|
||||||
() => inlineSvelteComponentAccept(
|
|
||||||
svelteComponentManifest,
|
svelteComponentManifest,
|
||||||
variantNum,
|
variantNum,
|
||||||
paramValues,
|
paramValues,
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
),
|
);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = withSourceLockSync(
|
||||||
|
path.resolve(process.cwd(), sourceFile),
|
||||||
|
requestedOperation + ':' + id,
|
||||||
|
runOperation,
|
||||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
result = operationFailure(err, {
|
result = operationFailure(err, resultContext);
|
||||||
file: svelteComponentManifest.sourceFile,
|
|
||||||
sourceFile: svelteComponentManifest.sourceFile,
|
|
||||||
previewMode: 'svelte-component',
|
|
||||||
componentDir: svelteComponentManifest.componentDir,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (result.carbonize) {
|
if (result.carbonize) {
|
||||||
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
|
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
|
||||||
|
|||||||
@@ -97,23 +97,20 @@
|
|||||||
return { value: c.value, label: c.label };
|
return { value: c.value, label: c.label };
|
||||||
});
|
});
|
||||||
|
|
||||||
const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions'];
|
// The Live chrome inventory (which surfaces exist, and the element ids each
|
||||||
const LIVE_UI_SURFACES = [
|
// one owns) comes from the canonical source, skill/scripts/live/ui-surfaces.mjs,
|
||||||
{ 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'] },
|
// which the /live.js assembler serializes into these globals alongside the
|
||||||
{ key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] },
|
// token/port/vocabulary. This file is served raw and injected as a classic
|
||||||
{ key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] },
|
// script, so it cannot import that module; the private impeccable-site repo
|
||||||
{ key: 'action-picker', ids: [PREFIX + '-picker'] },
|
// imports it directly to check its Live UI lab holds a snapshot for every
|
||||||
{ key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] },
|
// surface, which only works while the list has exactly one definition.
|
||||||
{ key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] },
|
// Add a surface in ui-surfaces.mjs, not here.
|
||||||
{ key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] },
|
const LIVE_CHROME_MOUNT_CONTRACT = Array.isArray(window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__)
|
||||||
{ key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] },
|
? window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__
|
||||||
{ key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] },
|
: ['root', 'transport', 'state', 'actions'];
|
||||||
{ 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'] },
|
const LIVE_UI_SURFACES = Array.isArray(window.__IMPECCABLE_LIVE_UI_SURFACES__)
|
||||||
{ key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] },
|
? window.__IMPECCABLE_LIVE_UI_SURFACES__
|
||||||
{ 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'] },
|
|
||||||
];
|
|
||||||
const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))];
|
const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))];
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import path from 'node:path';
|
|||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||||
|
const BATCH_OP_TEXT_LIMIT = 240;
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
||||||
const repairLines = batch?.repair ? [
|
const compactBatch = compactBatchForPrompt(batch);
|
||||||
|
const repairLines = compactBatch.repair ? [
|
||||||
'',
|
'',
|
||||||
'Repair mode:',
|
'Repair mode:',
|
||||||
'- The previous Apply attempt changed source, but validation failed.',
|
'- 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.',
|
'- 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.',
|
'- Keep failed and notes as arrays.',
|
||||||
'- Return the same canonical JSON shape after repair.',
|
'- Return the same canonical JSON shape after repair.',
|
||||||
JSON.stringify(batch.repair, null, 2),
|
JSON.stringify(compactBatch.repair, null, 2),
|
||||||
] : [];
|
] : [];
|
||||||
return [
|
return [
|
||||||
'You are the Impeccable staged copy-edit batch applier.',
|
'You are the Impeccable staged copy-edit batch applier.',
|
||||||
@@ -80,7 +82,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
|||||||
...repairLines,
|
...repairLines,
|
||||||
'',
|
'',
|
||||||
'Staged copy-edit batch:',
|
'Staged copy-edit batch:',
|
||||||
JSON.stringify(compactBatchForPrompt(batch), null, 2),
|
JSON.stringify(compactBatch, null, 2),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +294,7 @@ function readManualEditValidationScript(cwd) {
|
|||||||
function compactBatchForPrompt(batch) {
|
function compactBatchForPrompt(batch) {
|
||||||
return {
|
return {
|
||||||
pageUrl: batch?.pageUrl || null,
|
pageUrl: batch?.pageUrl || null,
|
||||||
repair: batch?.repair || undefined,
|
repair: compactBatchRepair(batch?.repair),
|
||||||
entries: (batch?.entries || []).map((entry) => ({
|
entries: (batch?.entries || []).map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
pageUrl: entry.pageUrl,
|
pageUrl: entry.pageUrl,
|
||||||
@@ -300,7 +302,71 @@ function compactBatchForPrompt(batch) {
|
|||||||
element: compactContextForBatch(entry.element),
|
element: compactContextForBatch(entry.element),
|
||||||
ops: (entry.ops || []).map(compactBatchOp),
|
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,
|
contextRef: op.contextRef,
|
||||||
tag: op.tag,
|
tag: op.tag,
|
||||||
elementId: op.elementId,
|
elementId: op.elementId,
|
||||||
classes: op.classes,
|
classes: compactBatchStringList(op.classes, 24),
|
||||||
originalText: op.originalText,
|
originalText: op.originalText,
|
||||||
newText: op.newText,
|
newText: op.newText,
|
||||||
deleted: op.deleted === true || undefined,
|
deleted: op.deleted === true || undefined,
|
||||||
sourceHint: op.sourceHint,
|
sourceHint: normalizeBatchSourceHint(op.sourceHint),
|
||||||
leaf: compactContextForBatch(op.leaf),
|
leaf: compactContextForBatch(op.leaf),
|
||||||
nearbyEditableTexts: Array.isArray(op.nearbyEditableTexts) ? op.nearbyEditableTexts.slice(0, 8) : [],
|
nearbyEditableTexts: compactNearbyBatchTexts(op.nearbyEditableTexts),
|
||||||
container: compactContextForBatch(op.container),
|
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) {
|
function compactContextForBatch(value) {
|
||||||
if (!value || typeof value !== 'object') return value || null;
|
if (!value || typeof value !== 'object') return value || null;
|
||||||
return {
|
return {
|
||||||
ref: value.ref,
|
ref: compactBatchString(value.ref),
|
||||||
tagName: value.tagName,
|
tagName: compactBatchString(value.tagName),
|
||||||
id: value.id,
|
id: compactBatchString(value.id),
|
||||||
classes: value.classes,
|
classes: compactBatchStringList(value.classes, 24),
|
||||||
textContent: truncate(value.textContent, 900),
|
textContent: truncate(value.textContent, 900),
|
||||||
outerHTML: truncate(stripLiveRuntimeHtml(value.outerHTML), 1800),
|
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) {
|
if (env.IMPECCABLE_LIVE_COPY_AGENT_MODEL) {
|
||||||
args.push('--model', 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
|
// 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
|
// through. On macOS, `claude /login` stores creds in the Keychain, which a
|
||||||
// non-TTY subprocess cannot read; setting CLAUDE_CODE_OAUTH_TOKEN (via
|
// non-TTY subprocess cannot read; setting CLAUDE_CODE_OAUTH_TOKEN (via
|
||||||
// `claude setup-token`) is the supported headless auth path.
|
// `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 }) {
|
function runAgentProcess(command, args, stdin, { cwd, env, logPath, timeoutMs, mirrorOutputPath }) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* node live.mjs --help
|
* node live.mjs --help
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -316,11 +316,17 @@ function globToRegex(pattern) {
|
|||||||
|
|
||||||
function runScript(name, args, options = {}) {
|
function runScript(name, args, options = {}) {
|
||||||
const scriptPath = path.join(__dirname, name);
|
const scriptPath = path.join(__dirname, name);
|
||||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
|
||||||
try {
|
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) {
|
} 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 || '';
|
return err.stdout || err.message || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
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([
|
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
|
||||||
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
|
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
|
||||||
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.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 =
|
const prelude =
|
||||||
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
||||||
`window.__IMPECCABLE_PORT__ = ${port};\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` +
|
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
|
||||||
// Canonical command vocabulary (values + labels + icons). live-browser.js
|
// Canonical command vocabulary (values + labels + icons). live-browser.js
|
||||||
// builds its action picker from this instead of an inline copy.
|
// 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 body = parts.map((part) => {
|
||||||
const file = part.file || path.basename(part.path || '');
|
const file = part.file || path.basename(part.path || '');
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
]);
|
||||||
@@ -22,6 +22,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
// All known harness directories
|
// All known harness directories
|
||||||
const HARNESS_DIRS = [
|
const HARNESS_DIRS = [
|
||||||
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
|
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
|
||||||
|
'.hermes',
|
||||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -29,28 +29,53 @@
|
|||||||
* "materials": ["letterpress", "newsprint"], // optional, rendered as tags
|
* "materials": ["letterpress", "newsprint"], // optional, rendered as tags
|
||||||
* "viewport": "one line: the first-viewport composition", // optional
|
* "viewport": "one line: the first-viewport composition", // optional
|
||||||
* "case": "one line: the fusion verdict, honest", // 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
|
* "risk": "one line: the honest risk", // optional
|
||||||
* "body": "fallback prose when the structured fields are absent",
|
* "body": "fallback prose when the structured fields are absent",
|
||||||
* "sketch": ".impeccable/sketches/assigned.webp", // optional; may not exist
|
* "comp": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
|
||||||
* // yet: the page shimmer-waits and polls the
|
* // full-fidelity direction comp (the legacy
|
||||||
* // slot until the file lands, so serve first
|
* // key "sketch" is accepted as an alias). May
|
||||||
* // and generate after
|
* // 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;
|
* "hero": "https://... or /abs/path.webp", // optional inspiration image;
|
||||||
* // rides picture-in-picture when a sketch exists
|
* // rides picture-in-picture when a comp exists
|
||||||
* "board": "https://... or /abs/path.webp" // optional secondary image
|
* "board": "https://... or /abs/path.webp" // optional secondary image
|
||||||
* }, ...
|
* }, ...
|
||||||
* ],
|
* ],
|
||||||
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
|
* "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;
|
* "canon": true, // adds the "Play it straight" standing exit;
|
||||||
* // direction rounds only (returns {"optionId":"canon"})
|
* // direction rounds only (returns {"optionId":"canon"})
|
||||||
* "canonCard": { ... }, // optional: the standing exit as a full card with the
|
* "canonCard": { ... }, // optional: the standing exit as a full card with the
|
||||||
* // same anatomy (label, thesis, palette, sketch, ...);
|
* // same anatomy (label, thesis, palette, comp, ...);
|
||||||
* // rendered last and visually subordinate. Without it,
|
* // rendered last and visually subordinate. Without it,
|
||||||
* // canon stays a quiet footer action.
|
* // 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
|
* Options render as large cards: the comp leads when present, with the
|
||||||
* inspiration image picture-in-picture; a hero alone renders full-bleed; a
|
* inspiration image picture-in-picture; a hero alone renders full-bleed; a
|
||||||
* text-only direction gets its identity from the palette chips and tags.
|
* text-only direction gets its identity from the palette chips and tags.
|
||||||
* Local image paths are served by this server; nothing is uploaded anywhere.
|
* Local image paths are served by this server; nothing is uploaded anywhere.
|
||||||
@@ -123,12 +148,30 @@ function printAnswer(raw) {
|
|||||||
if (a.hero || a.board) {
|
if (a.hero || a.board) {
|
||||||
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.");
|
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) {
|
if (a.comp) {
|
||||||
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') {
|
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.');
|
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.');
|
||||||
|
}
|
||||||
|
if (a.buildPath === 'comp' || a.buildPath === 'code') {
|
||||||
|
// The page never writes the flip itself, but "never write it" overstated
|
||||||
|
// that into a rule the agent then applied to new-work's one-time offer,
|
||||||
|
// which exists for exactly this case: a flip on a project that had no
|
||||||
|
// recorded default is the only moment the preference is ever asked for.
|
||||||
|
const origin = a.buildPathFlipped
|
||||||
|
? 'flipped on the page, so it binds this session only, and the page never writes it back; the sole exception is new-work’s one-time offer, on a project that had no recorded default at all, which asks after the round closes and writes the answer to .impeccable/config.json'
|
||||||
|
: 'the round’s recorded default';
|
||||||
|
console.log(`BUILD PATH: ${a.buildPath} (${origin}). ${a.buildPath === 'comp'
|
||||||
|
? 'Comp-led: the chosen card’s comp is law; generate it before building when it does not exist yet, and the finish review audits the build against it.'
|
||||||
|
: 'Code-led: no comp is owed; a comp that already rendered rides at the finish review as the critique reference, and the ambition lives in the direction contract.'}`);
|
||||||
|
}
|
||||||
} catch { /* raw answer */ }
|
} catch { /* raw answer */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,21 +181,29 @@ const portArg = Number(arg('port', '0'));
|
|||||||
const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions');
|
const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions');
|
||||||
const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`);
|
const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`);
|
||||||
const answerFile = (key) => path.join(QUESTION_DIR, `${key}.answer.json`);
|
const answerFile = (key) => path.join(QUESTION_DIR, `${key}.answer.json`);
|
||||||
|
// A code-to-comp flip mid-round: the page records it here and --wait
|
||||||
|
// surfaces it as its own event, because the agent must start generating
|
||||||
|
// comps while the round is still open. Comp-to-code needs no event; it is
|
||||||
|
// free and rides the final ANSWER.
|
||||||
|
const flipFile = (key) => path.join(QUESTION_DIR, `${key}.flip.json`);
|
||||||
|
|
||||||
if (hasFlag('schema')) {
|
if (hasFlag('schema')) {
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
title: 'Choose the visual world',
|
title: 'Choose the visual world',
|
||||||
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
|
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
|
||||||
options: [
|
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/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.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.' }], comp: '.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: '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: 'model-pick', label: 'The Broadside Ballad', kicker: 'IMPECCABLE’S 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.', comp: '.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.', comp: '.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'] },
|
||||||
|
buildPath: { value: 'comp', toggle: true },
|
||||||
canon: true,
|
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.', comp: '.impeccable/mocks/decision/canon.webp' },
|
||||||
steer: true,
|
steer: true,
|
||||||
}, null, 2));
|
}, 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. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. 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/comp accept URLs or local paths; comp 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 comp, 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 comp (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 "IMPECCABLE’S 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. A card may instead declare "wireframe" ({"cols":12,"rows":10,"regions":[{"label":"nav rail","x":0,"y":0,"w":3,"h":10,"accent":true}]}): the page draws it as a layout schematic in the media slot; surface-scope rounds use it on code-led builds, it never counts toward salience, and the card keeps its full read on the front. The comp slot carries the card’s full-fidelity direction comp (the legacy key "sketch" is accepted as an alias). 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. buildPath rides the payload as { "value": "comp"|"code", "toggle": true }: the value is the recorded default (.impeccable/config.json buildPath, or .impeccable/config.local.json where one machine differs) and the toggle renders a footer switch whose flip binds that session only; the ANSWER then carries buildPath plus buildPathFlipped. On a code-led round each card still declares its comp path as a flip reserve: wireframes render, and a flip to comp makes --wait return once with BUILD PATH FLIPPED so you generate the comps into the declared slots while the round stays open; a flip back to code is free, and a comp that already landed stays as the critique reference. The toggle may only be offered when image generation exists: a harness with no image tool and no API key never sets toggle: true, so the choice never renders where comps cannot be made, and code-led simply rides as the untoggleable value. followup: true keeps the table open after a pick for a second round via --update; send the next payload immediately, the page is waiting on it.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +230,13 @@ if (hasFlag('wait')) {
|
|||||||
let sawClose = false;
|
let sawClose = false;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (answered()) break;
|
if (answered()) break;
|
||||||
|
// A build-path flip is its own event, not an answer: the round stays
|
||||||
|
// open, and the agent's job right now is comps, not code.
|
||||||
|
if (fs.existsSync(flipFile(key))) {
|
||||||
|
try { fs.rmSync(flipFile(key)); } catch { /* consumed elsewhere */ }
|
||||||
|
console.log('BUILD PATH FLIPPED: comp (for this session only; never write it to settings). The table is still open and the page shows shimmer where the images will land: generate each open card’s comp into its declared path now, lead first, then collect the answer with --wait again. A card whose comp already exists needs nothing.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
if (!alive()) {
|
if (!alive()) {
|
||||||
console.log('serve-question: the question server is gone with no answer. This is a server failure, not a user decision: restart it with --start and the same payload, reopen the URL for the user, and wait again. Never proceed without their choice while their browser session is open.');
|
console.log('serve-question: the question server is gone with no answer. This is a server failure, not a user decision: restart it with --start and the same payload, reopen the URL for the user, and wait again. Never proceed without their choice while their browser session is open.');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
@@ -196,12 +254,16 @@ if (hasFlag('wait')) {
|
|||||||
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
|
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();
|
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
|
||||||
printAnswer(collected);
|
printAnswer(collected);
|
||||||
// A re-roll keeps the table open: the server stays alive awaiting --update,
|
// A re-roll or a followup-round pick keeps the table open: the server stays
|
||||||
// so only the answer file is consumed. Terminal choices clean up fully.
|
// alive awaiting --update, so only the answer file is consumed. Terminal
|
||||||
let isRerollAnswer = false;
|
// choices clean up fully.
|
||||||
try { isRerollAnswer = JSON.parse(collected).optionId === 'reroll'; } catch { /* treat as terminal */ }
|
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 */ }
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +332,12 @@ else raw = fs.readFileSync(0, 'utf8');
|
|||||||
let payload;
|
let payload;
|
||||||
let options;
|
let options;
|
||||||
let localImages = [];
|
let localImages = [];
|
||||||
|
// Build path (comp-led vs code-led): the payload carries the recorded
|
||||||
|
// default; the page's toggle updates the live value per session. The server
|
||||||
|
// owns both so the final ANSWER states the path and whether it was flipped
|
||||||
|
// even when the round never rendered a toggle.
|
||||||
|
let buildPathDefault = null;
|
||||||
|
let liveBuildPath = null;
|
||||||
|
|
||||||
function loadRound(json) {
|
function loadRound(json) {
|
||||||
const parsed = JSON.parse(json);
|
const parsed = JSON.parse(json);
|
||||||
@@ -285,10 +353,10 @@ function loadRound(json) {
|
|||||||
localImages.push(abs);
|
localImages.push(abs);
|
||||||
return `/img/${localImages.length - 1}`;
|
return `/img/${localImages.length - 1}`;
|
||||||
};
|
};
|
||||||
// Sketches stream in after the page is served, so their slots register
|
// Comps stream in after the page is served, so their slots register
|
||||||
// whether or not the file exists yet; /img answers 404 until it lands and
|
// whether or not the file exists yet; /img answers 404 until it lands and
|
||||||
// the page polls the slot. Remote sketch URLs pass through untouched.
|
// the page polls the slot. Remote comp URLs pass through untouched.
|
||||||
const sketchSrc = (value) => {
|
const compSrc = (value) => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
if (/^https?:\/\//.test(value)) return value;
|
if (/^https?:\/\//.test(value)) return value;
|
||||||
localImages.push(path.resolve(value));
|
localImages.push(path.resolve(value));
|
||||||
@@ -299,14 +367,26 @@ function loadRound(json) {
|
|||||||
...option,
|
...option,
|
||||||
heroSrc: imageSrc(option.hero),
|
heroSrc: imageSrc(option.hero),
|
||||||
boardSrc: imageSrc(option.board),
|
boardSrc: imageSrc(option.board),
|
||||||
sketchSrc: sketchSrc(option.sketch),
|
compSrc: compSrc(option.comp ?? option.sketch),
|
||||||
});
|
});
|
||||||
options = parsed.options.map(decorate);
|
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
|
// The standing exit as a full card: same anatomy, reserved id, rendered
|
||||||
// subordinate by the page. Without it, canon stays the quiet footer action.
|
// subordinate by the page. Without it, canon stays the quiet footer action.
|
||||||
if (parsed.canonCard && typeof parsed.canonCard === 'object') {
|
if (parsed.canonCard && typeof parsed.canonCard === 'object') {
|
||||||
options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
|
options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
|
||||||
}
|
}
|
||||||
|
options = [...options, ...declined];
|
||||||
|
buildPathDefault = (parsed.buildPath && (parsed.buildPath.value === 'comp' || parsed.buildPath.value === 'code'))
|
||||||
|
? { value: parsed.buildPath.value, toggle: parsed.buildPath.toggle === true }
|
||||||
|
: null;
|
||||||
|
liveBuildPath = buildPathDefault?.value ?? null;
|
||||||
}
|
}
|
||||||
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
|
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
|
||||||
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
|
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
|
||||||
@@ -322,7 +402,26 @@ function page() {
|
|||||||
// and material tags give a text-only direction an immediate identity that
|
// and material tags give a text-only direction an immediate identity that
|
||||||
// no generation luck can distort.
|
// 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 fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
|
||||||
const hasMedia = (option) => Boolean(option.sketchSrc || option.heroSrc || option.boardSrc);
|
const demoted = (option) => option.verdict === 'declined';
|
||||||
|
// The build path (comp-led vs code-led) is a workflow preference, not a
|
||||||
|
// design decision: the payload carries the recorded default and whether
|
||||||
|
// the page offers the toggle. On a code-led round a declared comp path is
|
||||||
|
// a flip reserve, not a face: wireframes render, and the slot only starts
|
||||||
|
// shimmering when the user flips to comp.
|
||||||
|
const buildPath = buildPathDefault;
|
||||||
|
const codeLed = buildPath?.value === 'code';
|
||||||
|
// 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].compSrc || options[0].heroSrc || options[0].boardSrc));
|
||||||
|
// A declined card never renders a full media face, comp included: even a
|
||||||
|
// declared comp would buy back the salience the verdict took away.
|
||||||
|
const faceComp = (option) => (demoted(option) || codeLed) ? null : option.compSrc;
|
||||||
|
const thumbOnly = (option) => !faceComp(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
|
||||||
|
const hasMedia = (option) => Boolean(faceComp(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
|
||||||
// The back exists to keep long facts off a card whose front is an image;
|
// 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
|
// 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.
|
// the full read lives on the front instead.
|
||||||
@@ -338,9 +437,34 @@ function page() {
|
|||||||
idBits.push(option.materials.slice(0, 4).map((m) => `<span class="tag">${esc(m)}</span>`).join(''));
|
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>`);
|
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">From ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
|
||||||
|
const raisesHead = (count) => `<div class="raises-head"><span class="fact-label">Improved by Impeccable's worlds</span>${count > 1 ? `<span class="raises-count" data-raises-count>1/${count}</span>` : ''}</div>`;
|
||||||
|
if (raiseLines.length > 1) {
|
||||||
|
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter for the next improvement" aria-label="How Impeccable's worlds improved this direction; activate to see the next improvement">
|
||||||
|
${raisesHead(raiseLines.length)}
|
||||||
|
${raiseLines.join('')}
|
||||||
|
<span class="sr-live" aria-live="polite"></span>
|
||||||
|
</div>`);
|
||||||
|
} else {
|
||||||
|
rows.push(`<div class="raises">${raisesHead(1)}${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
|
// 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
|
// 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.
|
// the card's back; once the comp lands, the first viewport is a picture.
|
||||||
// With no art there is no back, so the full read fills the room the
|
// With no art there is no back, so the full read fills the room the
|
||||||
// image would have taken.
|
// image would have taken.
|
||||||
if (hasMedia(option)) {
|
if (hasMedia(option)) {
|
||||||
@@ -348,6 +472,7 @@ function page() {
|
|||||||
} else {
|
} else {
|
||||||
rows.push(fact('First viewport', option.viewport));
|
rows.push(fact('First viewport', option.viewport));
|
||||||
rows.push(fact('The case', option.case));
|
rows.push(fact('The case', option.case));
|
||||||
|
rows.push(fact('Kept', option.kept));
|
||||||
rows.push(fact('Risk', option.risk));
|
rows.push(fact('Risk', option.risk));
|
||||||
}
|
}
|
||||||
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
|
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
|
||||||
@@ -357,25 +482,32 @@ function page() {
|
|||||||
const backFacts = (option) => [
|
const backFacts = (option) => [
|
||||||
fact('First viewport', option.viewport),
|
fact('First viewport', option.viewport),
|
||||||
fact('The case', option.case),
|
fact('The case', option.case),
|
||||||
|
fact('Kept', option.kept),
|
||||||
fact('Risk', option.risk),
|
fact('Risk', option.risk),
|
||||||
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
|
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
|
||||||
].filter(Boolean).join('\n ');
|
].filter(Boolean).join('\n ');
|
||||||
const media = (option) => {
|
const media = (option) => {
|
||||||
const inspiration = option.heroSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
const inspirationSrc = option.heroSrc || option.boardSrc;
|
||||||
<img src="${esc(option.heroSrc)}" alt="">
|
const inspiration = inspirationSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||||
|
<img src="${esc(inspirationSrc)}" alt="">
|
||||||
<figcaption>inspiration</figcaption>
|
<figcaption>inspiration</figcaption>
|
||||||
</figure>` : '';
|
</figure>` : '';
|
||||||
const details = hasBack(option) ? flipChip('Details') : '';
|
const details = hasBack(option) ? flipChip('Details') : '';
|
||||||
if (option.sketchSrc) {
|
// Thumb-only art renders inside the body via anatomy(), never as a face,
|
||||||
return `<div class="media sketching" data-sketch="${esc(option.sketchSrc)}">
|
// and a declined card's comp slot is ignored outright.
|
||||||
<div class="shimmer"><span class="sketch-note">sketching…</span></div>
|
if (thumbOnly(option)) return '';
|
||||||
<img class="sketch" alt="" hidden>
|
if (faceComp(option)) {
|
||||||
|
const textOnlyFacts = backFacts(option);
|
||||||
|
return `<div class="media comp-pending" data-comp="${esc(option.compSrc)}">
|
||||||
|
<div class="shimmer"><span class="comp-note">rendering…</span></div>
|
||||||
|
<img class="comp" alt="" hidden>
|
||||||
${inspiration}
|
${inspiration}
|
||||||
|
<template class="text-only-facts">${textOnlyFacts}</template>
|
||||||
<div class="chips">${expandChip}${details}</div>
|
<div class="chips">${expandChip}${details}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
if (option.heroSrc || option.boardSrc) {
|
if (option.heroSrc || option.boardSrc) {
|
||||||
// Without a sketch the catalog art is the card's face; it stays a
|
// Without a comp the catalog art is the card's face; it stays a
|
||||||
// labeled reference so it never reads as the promise of the build.
|
// 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.">
|
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="">
|
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||||
@@ -385,17 +517,41 @@ function page() {
|
|||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
// Wireframe media: a code-led card's layout schematic, authored as grid
|
||||||
|
// regions in the payload and drawn by the page; boxes and labels, no art.
|
||||||
|
// It fills the media slot only when the card has no imagery, and it never
|
||||||
|
// counts toward salience or earns a card back: the full read stays on the
|
||||||
|
// front, exactly like a text-only card.
|
||||||
|
const wire = (option) => {
|
||||||
|
const frame = option.wireframe;
|
||||||
|
if (!frame || !Array.isArray(frame.regions) || !frame.regions.length || media(option) || demoted(option)) return '';
|
||||||
|
const cols = Number(frame.cols) > 0 ? Number(frame.cols) : 12;
|
||||||
|
const rows = Number(frame.rows) > 0 ? Number(frame.rows) : 10;
|
||||||
|
const pct = (n, total) => `${Math.max(0, Math.min(100, (n / total) * 100)).toFixed(2)}%`;
|
||||||
|
const cells = frame.regions.slice(0, 12).map((region) => {
|
||||||
|
const x = Number(region.x) || 0;
|
||||||
|
const y = Number(region.y) || 0;
|
||||||
|
const w = Math.max(Number(region.w) || 1, 0.5);
|
||||||
|
const h = Math.max(Number(region.h) || 1, 0.5);
|
||||||
|
return `<div class="wire-region${region.accent ? ' accent' : ''}" style="left:${pct(x, cols)};top:${pct(y, rows)};width:${pct(w, cols)};height:${pct(h, rows)}"><span>${esc(region.label || '')}</span></div>`;
|
||||||
|
}).join('');
|
||||||
|
return `<div class="media wire" role="img" aria-label="Layout schematic">
|
||||||
|
<div class="wire-field">${cells}</div>
|
||||||
|
<p class="media-label">layout</p>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this';
|
||||||
const cards = options.map((option, index) => `
|
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)}"${codeLed && option.compSrc && !demoted(option) ? ` data-comp-slot="${esc(option.compSrc)}"` : ''}>
|
||||||
<div class="card-inner">
|
<div class="card-inner">
|
||||||
<div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
|
<div class="face front${index === 0 ? ' lead' : ''}${(media(option) || wire(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)}
|
${media(option) || wire(option)}
|
||||||
<div class="body">
|
<div class="body">
|
||||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||||
<h2>${esc(option.label)}</h2>
|
<h2>${esc(option.label)}</h2>
|
||||||
${anatomy(option)}
|
${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>
|
||||||
</div>
|
</div>
|
||||||
${hasBack(option) ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
${hasBack(option) ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||||
@@ -406,7 +562,7 @@ function page() {
|
|||||||
<div class="body back-body">
|
<div class="body back-body">
|
||||||
${option.boardSrc ? `<p class="tier">The full read · ${esc(option.label)}</p>` : ''}
|
${option.boardSrc ? `<p class="tier">The full read · ${esc(option.label)}</p>` : ''}
|
||||||
${backFacts(option)}
|
${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>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -439,9 +595,12 @@ function page() {
|
|||||||
--ks-font-display: "Alumni Sans", "Albert Sans", Arial, sans-serif;
|
--ks-font-display: "Alumni Sans", "Albert Sans", Arial, sans-serif;
|
||||||
--ks-font: "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, system-ui, sans-serif;
|
--ks-font: "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, system-ui, sans-serif;
|
||||||
--ks-mono: "SFMono-Regular", "Roboto Mono", "JetBrains Mono", Consolas, monospace;
|
--ks-mono: "SFMono-Regular", "Roboto Mono", "JetBrains Mono", Consolas, monospace;
|
||||||
|
/* One inset shared by the content column, the deck's snap padding, and
|
||||||
|
the sticky footer, so all three align on the same 90rem column. */
|
||||||
|
--page-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2));
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; margin: 0; }
|
* { box-sizing: border-box; margin: 0; }
|
||||||
body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 2rem; min-height: 100dvh; display: flex; flex-direction: column; overflow-x: clip; }
|
body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 0; min-height: 100dvh; display: flex; flex-direction: column; overflow-x: clip; }
|
||||||
#ambient { position: fixed; inset: -40px; z-index: 0; background-size: cover; background-position: center; filter: blur(34px) saturate(1.05); opacity: 0; transition: opacity .55s ease, background-image .2s; pointer-events: none; }
|
#ambient { position: fixed; inset: -40px; z-index: 0; background-size: cover; background-position: center; filter: blur(34px) saturate(1.05); opacity: 0; transition: opacity .55s ease, background-image .2s; pointer-events: none; }
|
||||||
#scrim { position: fixed; inset: 0; z-index: 0; background: linear-gradient(180deg, oklch(7% 0.006 95 / 0.62), oklch(7% 0.006 95 / 0.78)); pointer-events: none; }
|
#scrim { position: fixed; inset: 0; z-index: 0; background: linear-gradient(180deg, oklch(7% 0.006 95 / 0.62), oklch(7% 0.006 95 / 0.78)); pointer-events: none; }
|
||||||
header, main, footer { position: relative; z-index: 1; }
|
header, main, footer { position: relative; z-index: 1; }
|
||||||
@@ -453,7 +612,7 @@ function page() {
|
|||||||
.brand { display: flex; align-items: center; gap: .55rem; color: var(--ks-kinpaku); }
|
.brand { display: flex; align-items: center; gap: .55rem; color: var(--ks-kinpaku); }
|
||||||
.brand svg { width: 22px; height: 22px; }
|
.brand svg { width: 22px; height: 22px; }
|
||||||
.wordmark { font-family: var(--ks-font-display); font-weight: 400; font-size: 1.125rem; letter-spacing: 0.15em; text-transform: uppercase; line-height: 1; color: var(--ks-kinpaku); }
|
.wordmark { font-family: var(--ks-font-display); font-weight: 400; font-size: 1.125rem; letter-spacing: 0.15em; text-transform: uppercase; line-height: 1; color: var(--ks-kinpaku); }
|
||||||
.headline { display: flex; align-items: center; gap: .9rem; }
|
.headline { display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; }
|
||||||
.headline-die { flex: none; width: 34px; height: 34px; color: var(--ks-kinpaku); }
|
.headline-die { flex: none; width: 34px; height: 34px; color: var(--ks-kinpaku); }
|
||||||
h1 { font-family: var(--ks-font-display); font-weight: 100; font-size: clamp(2.6rem, 5vw, 4.2rem); letter-spacing: -0.01em; line-height: 1.02; color: var(--ks-champagne); }
|
h1 { font-family: var(--ks-font-display); font-weight: 100; font-size: clamp(2.6rem, 5vw, 4.2rem); letter-spacing: -0.01em; line-height: 1.02; color: var(--ks-champagne); }
|
||||||
.question { color: var(--ks-text-muted); margin-top: .7rem; max-width: 52rem; }
|
.question { color: var(--ks-text-muted); margin-top: .7rem; max-width: 52rem; }
|
||||||
@@ -465,12 +624,24 @@ function page() {
|
|||||||
.deck-shell { position: relative; width: 100vw; margin-left: calc(50% - 50vw); }
|
.deck-shell { position: relative; width: 100vw; margin-left: calc(50% - 50vw); }
|
||||||
/* One row in a wide viewport, one column in a tall one; the deck scrolls on
|
/* One row in a wide viewport, one column in a tall one; the deck scrolls on
|
||||||
its axis with snap points and the arrows page it card by card. */
|
its axis with snap points and the arrows page it card by card. */
|
||||||
.grid { --deck-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2)); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; }
|
.grid { --deck-inset: var(--page-inset); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; }
|
||||||
.grid::-webkit-scrollbar { display: none; }
|
.grid::-webkit-scrollbar { display: none; }
|
||||||
/* Wide enough that the sketch carries the card: at 27vw the imagery read
|
/* Wide enough that the comp carries the card: at 27vw the imagery read
|
||||||
as a thumbnail above a column of copy, and the copy won the attention
|
as a thumbnail above a column of copy, and the copy won the attention
|
||||||
contest the sketch is supposed to win. */
|
contest the comp is supposed to win. */
|
||||||
.grid > .card { flex: 0 0 clamp(24rem, 34vw, 34rem); scroll-snap-align: center; }
|
.grid > .card { flex: 0 0 clamp(24rem, 34vw, 34rem); scroll-snap-align: center; }
|
||||||
|
/* Short landscape viewports (13-inch laptops): header, a 34vw card, and the
|
||||||
|
footer do not fit 800px of height, so the headline compacts and the deck
|
||||||
|
narrows. Height is the axis that gives; the sticky footer keeps the
|
||||||
|
round's verbs on screen while a too-tall card scrolls. */
|
||||||
|
@media (min-aspect-ratio: 1/1) and (max-height: 900px) {
|
||||||
|
body { padding-top: 1.1rem; }
|
||||||
|
h1 { font-size: clamp(2rem, 3.4vw, 2.9rem); }
|
||||||
|
.question { margin-top: .45rem; }
|
||||||
|
.stage { gap: 1rem; }
|
||||||
|
.grid > .card { flex-basis: clamp(20rem, 27vw, 27rem); }
|
||||||
|
.grid > .card.declined { flex-basis: clamp(13rem, 18vw, 18rem); }
|
||||||
|
}
|
||||||
.nav { position: absolute; z-index: 6; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: oklch(7% 0.006 95 / 0.78); border: 1px solid var(--ks-rule); color: var(--ks-kinpaku); cursor: pointer; backdrop-filter: blur(6px); transition: border-color .2s, color .2s, opacity .2s; }
|
.nav { position: absolute; z-index: 6; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: oklch(7% 0.006 95 / 0.78); border: 1px solid var(--ks-rule); color: var(--ks-kinpaku); cursor: pointer; backdrop-filter: blur(6px); transition: border-color .2s, color .2s, opacity .2s; }
|
||||||
.nav:hover { border-color: var(--ks-kinpaku-deep); color: var(--ks-kinpaku-pale); }
|
.nav:hover { border-color: var(--ks-kinpaku-deep); color: var(--ks-kinpaku-pale); }
|
||||||
.nav[disabled] { opacity: .25; cursor: default; }
|
.nav[disabled] { opacity: .25; cursor: default; }
|
||||||
@@ -497,6 +668,14 @@ function page() {
|
|||||||
.nav.next { right: auto; left: 50%; top: auto; bottom: 6px; transform: translate(-50%, 0); }
|
.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-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); }
|
.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; }
|
||||||
|
/* The sticky bar is a wide-viewport fix. Here it would sit over the
|
||||||
|
deck's More pager and cost a third of a phone screen, and the deck
|
||||||
|
already scrolls internally, so the footer stays in the page flow. */
|
||||||
|
footer { position: static; width: auto; margin: 1rem 0 0; padding: .7rem 0 1.2rem; background: transparent; border-top: 0; backdrop-filter: none; }
|
||||||
}
|
}
|
||||||
.card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
|
.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); }
|
.card:hover { transform: rotate(0deg) translateY(-4px); }
|
||||||
@@ -520,7 +699,7 @@ function page() {
|
|||||||
region entirely instead of reserving a blank 16:9 void. */
|
region entirely instead of reserving a blank 16:9 void. */
|
||||||
.face.text-only .kicker { position: static; align-self: flex-start; margin: 14px 0 0 14px; }
|
.face.text-only .kicker { position: static; align-self: flex-start; margin: 14px 0 0 14px; }
|
||||||
.face.text-only .body { padding-top: 12px; }
|
.face.text-only .body { padding-top: 12px; }
|
||||||
/* 16/10 matches the landscape sketch frame; portrait art overrides the
|
/* 16/10 matches the landscape comp frame; portrait art overrides the
|
||||||
slot with its own exact ratio at load (see the load listener), and the
|
slot with its own exact ratio at load (see the load listener), and the
|
||||||
deck narrows so portrait cards line up side by side. */
|
deck narrows so portrait cards line up side by side. */
|
||||||
.media { position: relative; width: 100%; aspect-ratio: 16/10; flex: none; }
|
.media { position: relative; width: 100%; aspect-ratio: 16/10; flex: none; }
|
||||||
@@ -556,14 +735,14 @@ function page() {
|
|||||||
.body.back-body { overflow-y: auto; flex: 1; scrollbar-width: thin; }
|
.body.back-body { overflow-y: auto; flex: 1; scrollbar-width: thin; }
|
||||||
/* Inspiration rides picture-in-picture: the catalog world explains where the
|
/* Inspiration rides picture-in-picture: the catalog world explains where the
|
||||||
direction comes from without promising what the build will look like. */
|
direction comes from without promising what the build will look like. */
|
||||||
/* Hovering the inspiration takes over the whole media region; the sketch is
|
/* Hovering the inspiration takes over the whole media region; the comp is
|
||||||
the promise, the inspiration is a glance, so the glance must cost nothing. */
|
the promise, the inspiration is a glance, so the glance must cost nothing. */
|
||||||
.pip { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; width: 84px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; background: var(--ks-lacquer); cursor: zoom-in; transition: left .35s cubic-bezier(.16,1,.3,1), bottom .35s cubic-bezier(.16,1,.3,1), width .35s cubic-bezier(.16,1,.3,1), height .35s cubic-bezier(.16,1,.3,1), border-radius .35s ease; box-shadow: 0 6px 18px oklch(0% 0 0 / 0.45); }
|
.pip { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; width: 84px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; background: var(--ks-lacquer); cursor: zoom-in; transition: left .35s cubic-bezier(.16,1,.3,1), bottom .35s cubic-bezier(.16,1,.3,1), width .35s cubic-bezier(.16,1,.3,1), height .35s cubic-bezier(.16,1,.3,1), border-radius .35s ease; box-shadow: 0 6px 18px oklch(0% 0 0 / 0.45); }
|
||||||
.pip img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
.pip img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||||
.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 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; }
|
.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); }
|
.comp-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
|
/* Catalog art standing in for a comp-less card is a reference, and says so
|
||||||
on its face; the same pill later carries "artwork unavailable". */
|
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); }
|
.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
|
/* Art that never arrives collapses to the card's own palette (painted
|
||||||
@@ -575,16 +754,63 @@ function page() {
|
|||||||
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
|
.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; }
|
.media.unavailable .chips { z-index: 2; }
|
||||||
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
||||||
the real sketch whenever it lands. */
|
the real comp whenever it lands. */
|
||||||
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
.media.stand-in img.comp { filter: brightness(.72) saturate(.85); }
|
||||||
.media.stand-in .pip { display: none; }
|
.media.stand-in .pip { display: none; }
|
||||||
.stand-in-label { position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; margin: 0; font-family: var(--ks-mono); font-size: .56rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 4px 0 5px; background: oklch(7% 0.006 95 / 0.78); backdrop-filter: blur(3px); }
|
.stand-in-label { position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; margin: 0; font-family: var(--ks-mono); font-size: .56rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 4px 0 5px; background: oklch(7% 0.006 95 / 0.78); backdrop-filter: blur(3px); }
|
||||||
.media.sketching { position: relative; }
|
.media.comp-pending { position: relative; }
|
||||||
.media.sketching .shimmer { position: absolute; inset: 0; }
|
.media.comp-pending .shimmer { position: absolute; inset: 0; }
|
||||||
.media img.sketch { position: relative; z-index: 1; }
|
.media img.comp { position: relative; z-index: 1; }
|
||||||
/* The generic .media img display:block would defeat [hidden] and float an
|
/* The generic .media img display:block would defeat [hidden] and float an
|
||||||
empty block over the shimmer; an unloaded sketch must truly not render. */
|
empty block over the shimmer; an unloaded comp must truly not render. */
|
||||||
.media img[hidden] { display: none; }
|
.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); }
|
||||||
|
/* Wireframe media: the code-led schematic. Quiet boxes in the card's own
|
||||||
|
chrome; uniform salience across cards by construction, so it needs no
|
||||||
|
parity rules. */
|
||||||
|
.media.wire { background: var(--ks-lacquer); border-bottom: 1px solid var(--ks-rule); }
|
||||||
|
.wire-field { position: absolute; inset: 12px 12px 26px; }
|
||||||
|
.wire-region { position: absolute; border: 1px solid oklch(78% 0 0 / 0.26); border-radius: 3px; background: oklch(78% 0 0 / 0.05); display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
||||||
|
.wire-region span { font-family: var(--ks-mono); font-size: .55rem; letter-spacing: .1em; text-transform: uppercase; color: var(--ks-text-faint); text-align: center; padding: 2px 4px; }
|
||||||
|
.wire-region.accent { border-color: oklch(84% 0.19 80.46 / 0.5); background: oklch(84% 0.19 80.46 / 0.06); }
|
||||||
|
.wire-region.accent span { color: var(--ks-kinpaku-rich); }
|
||||||
|
/* 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 improvements the dealt worlds donated to the assigned
|
||||||
|
direction, each named for its donor world. Patina, not kinpaku:
|
||||||
|
provenance, not a call to action. A quiet contained panel, never an
|
||||||
|
accent side-tab. */
|
||||||
|
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; padding: 7px 10px 8px; background: oklch(70% 0.12 188 / 0.06); border: 1px solid oklch(70% 0.12 188 / 0.22); border-radius: 8px; }
|
||||||
|
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; }
|
||||||
|
.raise .fact-label { color: var(--ks-patina); }
|
||||||
|
/* Several kept ideas cycle instead of stacking: one visible at a time, a
|
||||||
|
counter for the rest, the whole block advances on click. */
|
||||||
|
.raises-cycle { cursor: pointer; transition: border-color .2s ease; }
|
||||||
|
.raises-cycle:hover { border-color: oklch(70% 0.12 188 / 0.45); }
|
||||||
|
.raises-cycle .raise { display: none; }
|
||||||
|
.raises-cycle .raise.active { display: block; }
|
||||||
|
.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
|
/* 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. */
|
contender. Graphite instead of kinpaku, and it never takes the lead ring. */
|
||||||
.card.canon .face { border-color: var(--ks-rule); background: var(--ks-graphite); }
|
.card.canon .face { border-color: var(--ks-rule); background: var(--ks-graphite); }
|
||||||
@@ -594,12 +820,47 @@ function page() {
|
|||||||
.card.canon button.choose:hover { border-color: var(--ks-text-muted); background: var(--ks-graphite-2); }
|
.card.canon button.choose:hover { border-color: var(--ks-text-muted); background: var(--ks-graphite-2); }
|
||||||
button.choose { margin-top: auto; align-self: start; background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font-family: var(--ks-font); font-size: 1rem; font-weight: 500; line-height: 1.35; padding: 10px 38px; border-radius: 6px; cursor: pointer; transition: background .15s; }
|
button.choose { margin-top: auto; align-self: start; background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font-family: var(--ks-font); font-size: 1rem; font-weight: 500; line-height: 1.35; padding: 10px 38px; border-radius: 6px; cursor: pointer; transition: background .15s; }
|
||||||
button.choose:hover { background: var(--ks-kinpaku-pale); }
|
button.choose:hover { background: var(--ks-kinpaku-pale); }
|
||||||
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
/* The round's verbs stay reachable on short viewports: the footer is a
|
||||||
|
full-bleed bar stuck to the viewport bottom and the deck scrolls under
|
||||||
|
it. Same inset as the content column, so the controls stay aligned. */
|
||||||
|
footer { position: sticky; bottom: 0; z-index: 10; width: 100vw; margin: 1.2rem calc(50% - 50vw) 0; padding: .7rem var(--page-inset) calc(.7rem + env(safe-area-inset-bottom, 0px)); display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: oklch(7% 0.006 95 / 0.82); backdrop-filter: blur(10px); border-top: 1px solid var(--ks-rule); }
|
||||||
#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 { 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); }
|
#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; }
|
/* Build-path toggle: a workflow preference surfaced as a quiet segmented
|
||||||
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
control on the headline row, right-aligned opposite the title, its trade stated in
|
||||||
#reroll svg { width: 15px; height: 15px; }
|
one line that changes with the selection. The default comes from the
|
||||||
|
payload (settings); flipping binds this session only, and the agent
|
||||||
|
learns about a code-to-comp flip live. Rendered only when the payload
|
||||||
|
offers it, which the agent does only when image generation exists. */
|
||||||
|
#build-path { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; flex: none; margin-left: auto; }
|
||||||
|
.bp-switch { display: inline-flex; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; }
|
||||||
|
.bp-note { text-align: right; }
|
||||||
|
.bp-opt { font-family: var(--ks-mono); font-size: .62rem; letter-spacing: .12em; text-transform: uppercase; padding: 7px 12px; background: transparent; border: 0; color: var(--ks-text-faint); cursor: pointer; transition: color .2s ease, background-color .2s ease; }
|
||||||
|
.bp-opt + .bp-opt { border-left: 1px solid var(--ks-rule); }
|
||||||
|
.bp-opt.active { color: var(--ks-dark-ink); background: var(--ks-kinpaku-rich); }
|
||||||
|
.bp-opt:not(.active):hover { color: var(--ks-text); }
|
||||||
|
.bp-note { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .04em; color: var(--ks-text-faint); max-width: 21rem; line-height: 1.5; }
|
||||||
|
/* Flipping to comp starts billed, minutes-long generation, so it asks
|
||||||
|
first; flipping back is free and never does. */
|
||||||
|
#bp-confirm { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; background: oklch(4% 0.004 95 / 0.72); opacity: 0; transition: opacity .2s ease; }
|
||||||
|
#bp-confirm[hidden] { display: none; }
|
||||||
|
#bp-confirm.open { opacity: 1; }
|
||||||
|
.bp-confirm-panel { max-width: 26rem; margin: 1rem; background: var(--ks-lacquer-raised); border: 1px solid var(--ks-rule); border-radius: 10px; padding: 1.4rem 1.5rem 1.3rem; box-shadow: 0 30px 80px oklch(0% 0 0 / 0.55); }
|
||||||
|
.bp-confirm-panel h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; color: var(--ks-champagne); margin-bottom: .55rem; }
|
||||||
|
.bp-confirm-panel p { font-size: .875rem; line-height: 1.55; color: var(--ks-text-muted); }
|
||||||
|
.bp-confirm-actions { display: flex; gap: .6rem; margin-top: 1.1rem; }
|
||||||
|
.bp-confirm-go { background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font: inherit; font-weight: 500; padding: 9px 22px; border-radius: 6px; cursor: pointer; }
|
||||||
|
.bp-confirm-go:hover { background: var(--ks-kinpaku-pale); }
|
||||||
|
.bp-confirm-stay { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font: inherit; padding: 9px 18px; border-radius: 6px; cursor: pointer; }
|
||||||
|
.bp-confirm-stay:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
|
||||||
|
.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
|
/* 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,
|
to the dealt cards and the re-roll so it reads as the user's own door,
|
||||||
not a recommendation. */
|
not a recommendation. */
|
||||||
@@ -620,6 +881,16 @@ function page() {
|
|||||||
<div id="ambient" aria-hidden="true"></div>
|
<div id="ambient" aria-hidden="true"></div>
|
||||||
<div id="scrim" aria-hidden="true"></div>
|
<div id="scrim" aria-hidden="true"></div>
|
||||||
<div id="lightbox" hidden><img alt=""></div>
|
<div id="lightbox" hidden><img alt=""></div>
|
||||||
|
${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria-labelledby="bp-confirm-title" hidden>
|
||||||
|
<div class="bp-confirm-panel">
|
||||||
|
<h2 id="bp-confirm-title">Flip to comp-first?</h2>
|
||||||
|
<p>The agent starts rendering a comp for every open card right away, about a minute or two per card on your image provider, and the images land on the cards as they finish. This flip binds this session only.</p>
|
||||||
|
<div class="bp-confirm-actions">
|
||||||
|
<button type="button" class="bp-confirm-go" data-confirm>Render comps</button>
|
||||||
|
<button type="button" class="bp-confirm-stay" data-cancel>Keep code-first</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
<header>
|
<header>
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" 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>
|
<svg viewBox="0 0 24 24" fill="currentColor" 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>
|
||||||
@@ -631,6 +902,13 @@ function page() {
|
|||||||
<div class="headline">
|
<div class="headline">
|
||||||
<svg class="headline-die" 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>
|
<svg class="headline-die" 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>
|
||||||
<h1>${esc(payload.title || 'Choose a direction')}</h1>
|
<h1>${esc(payload.title || 'Choose a direction')}</h1>
|
||||||
|
${buildPath?.toggle ? `<div id="build-path" data-default="${buildPath.value}">
|
||||||
|
<div class="bp-switch" role="radiogroup" aria-label="Build path">
|
||||||
|
<button type="button" class="bp-opt" data-bp="comp" role="radio" aria-checked="false">Comp first</button>
|
||||||
|
<button type="button" class="bp-opt" data-bp="code" role="radio" aria-checked="false">Code first</button>
|
||||||
|
</div>
|
||||||
|
<p class="bp-note" data-bp-note></p>
|
||||||
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${payload.question ? `<p class="question">${esc(payload.question)}</p>` : ''}
|
${payload.question ? `<p class="question">${esc(payload.question)}</p>` : ''}
|
||||||
<div class="deck-shell">
|
<div class="deck-shell">
|
||||||
@@ -644,16 +922,33 @@ function page() {
|
|||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
|
${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>' : ''}
|
${payload.canon && !payload.canonCard ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
|
||||||
</footer>
|
</footer>
|
||||||
<script>
|
<script>
|
||||||
const steer = () => document.getElementById('steer')?.value || '';
|
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' }); } };
|
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
|
||||||
beat();
|
beat();
|
||||||
setInterval(beat, 5000);
|
setInterval(beat, 5000);
|
||||||
async function answer(optionId) {
|
async function answer(optionId) {
|
||||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
|
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.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)));
|
document.querySelectorAll('button.choose').forEach(b => b.addEventListener('click', () => answer(b.dataset.id)));
|
||||||
@@ -662,6 +957,25 @@ function page() {
|
|||||||
b.closest('.card').classList.toggle('flipped');
|
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 = 'Improvement ' + (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,
|
// Deal from the stack: cards begin piled at the grid's center, blurred,
|
||||||
// then travel to their seats with a stagger.
|
// then travel to their seats with a stagger.
|
||||||
const cards = [...document.querySelectorAll('.card')];
|
const cards = [...document.querySelectorAll('.card')];
|
||||||
@@ -695,46 +1009,147 @@ function page() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sketches stream in after the deal: poll each slot until the file lands,
|
// Comps stream in after the deal: poll each slot until the file lands,
|
||||||
// then swap the shimmer for the image. Generation is genuinely slow and a
|
// then swap the shimmer for the image. Generation is genuinely slow and a
|
||||||
// sequential batch puts the last card many minutes out, so patience is the
|
// sequential batch puts the last card many minutes out, so patience is the
|
||||||
// default: a slot only shows its inspiration as a stand-in when it has
|
// default: a slot only shows its inspiration as a stand-in when it has
|
||||||
// waited four minutes AND nothing has landed anywhere for four minutes, the
|
// waited four minutes AND nothing has landed anywhere for four minutes, the
|
||||||
// stand-in is labeled as such, and polling continues so the real sketch
|
// stand-in is labeled as such, and polling continues so the real comp
|
||||||
// still swaps in whenever it arrives. Progress anywhere resets patience.
|
// still swaps in whenever it arrives. Progress anywhere resets patience.
|
||||||
const landTracker = { last: Date.now() };
|
const landTracker = { last: Date.now() };
|
||||||
document.querySelectorAll('.media.sketching').forEach(m => {
|
const pollComp = (m) => {
|
||||||
const url = m.dataset.sketch;
|
const url = m.dataset.comp;
|
||||||
const img = m.querySelector('img.sketch');
|
const img = m.querySelector('img.comp');
|
||||||
const note = m.querySelector('.sketch-note');
|
const note = m.querySelector('.comp-note');
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
// A live elapsed count is the difference between "working" and "frozen".
|
// 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 settle = () => { clearInterval(tick); m.classList.remove('comp-pending', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
|
||||||
const standIn = () => {
|
const fallback = () => {
|
||||||
const pip = m.querySelector('.pip img');
|
const pip = m.querySelector('.pip img');
|
||||||
if (!pip || m.classList.contains('stand-in')) return;
|
if (pip) {
|
||||||
img.src = pip.getAttribute('src'); img.hidden = false;
|
if (m.classList.contains('stand-in')) return false;
|
||||||
m.classList.add('stand-in');
|
img.src = pip.getAttribute('src'); img.hidden = false;
|
||||||
m.querySelector('.shimmer')?.remove();
|
m.classList.add('stand-in');
|
||||||
clearInterval(tick);
|
m.querySelector('.shimmer')?.remove();
|
||||||
const label = document.createElement('p');
|
clearInterval(tick);
|
||||||
label.className = 'stand-in-label';
|
const label = document.createElement('p');
|
||||||
label.textContent = 'inspiration · sketch pending';
|
label.className = 'stand-in-label';
|
||||||
m.appendChild(label);
|
label.textContent = 'inspiration · comp pending';
|
||||||
|
m.appendChild(label);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No comp and no inspiration is the text-only card the payload would
|
||||||
|
// have rendered without a comp declaration. Bring the complete read
|
||||||
|
// forward before removing the now-unreachable back face.
|
||||||
|
const card = m.closest('.card');
|
||||||
|
const front = card?.querySelector('.face.front');
|
||||||
|
const body = front?.querySelector('.body');
|
||||||
|
const back = card?.querySelector('.face.back');
|
||||||
|
const textOnlyFacts = m.querySelector('template.text-only-facts');
|
||||||
|
const choose = body?.querySelector(':scope > button.choose');
|
||||||
|
if (body && textOnlyFacts && choose) {
|
||||||
|
const plainDetail = body.querySelector(':scope > .detail:not(.more)');
|
||||||
|
[...body.children].filter((el) => el.classList.contains('fact') || el.matches('.detail.more')).forEach((el) => el.remove());
|
||||||
|
choose.before(textOnlyFacts.content.cloneNode(true));
|
||||||
|
if (plainDetail) choose.before(plainDetail);
|
||||||
|
}
|
||||||
|
card?.classList.remove('flipped');
|
||||||
|
front?.classList.add('text-only');
|
||||||
|
back?.remove();
|
||||||
|
settle();
|
||||||
|
m.remove();
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
const tryLoad = () => {
|
const tryLoad = () => {
|
||||||
|
// A slot the user flipped back out of leaves the DOM; let its loop die.
|
||||||
|
if (!m.isConnected) { clearInterval(tick); return; }
|
||||||
const probe = new Image();
|
const probe = new Image();
|
||||||
probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); };
|
probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); };
|
||||||
probe.onerror = () => {
|
probe.onerror = () => {
|
||||||
const quiet = Date.now() - landTracker.last > 240000;
|
const quiet = Date.now() - landTracker.last > 240000;
|
||||||
if (Date.now() - started > 240000 && quiet) standIn();
|
if (Date.now() - started > 240000 && quiet && fallback()) return;
|
||||||
setTimeout(tryLoad, m.classList.contains('stand-in') ? 5000 : 2500);
|
setTimeout(tryLoad, m.classList.contains('stand-in') ? 5000 : 2500);
|
||||||
};
|
};
|
||||||
probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||||
};
|
};
|
||||||
tryLoad();
|
tryLoad();
|
||||||
});
|
};
|
||||||
|
document.querySelectorAll('.media.comp-pending').forEach(pollComp);
|
||||||
|
|
||||||
|
// Build-path toggle: the default is the round's recorded preference and
|
||||||
|
// flipping binds this session only. Flipping code to comp swaps every
|
||||||
|
// reserve slot (data-comp-slot) to its shimmer and tells the server, so
|
||||||
|
// the waiting agent starts generating; flipping back is free: pending
|
||||||
|
// slots return to their wireframes, a comp that already landed stays.
|
||||||
|
const bp = document.getElementById('build-path');
|
||||||
|
if (bp) {
|
||||||
|
const notes = {
|
||||||
|
comp: 'An image sets the bar first and the build must match it. Bolder composition; comps render before code.',
|
||||||
|
code: 'Code builds directly; the ambition is written into the contract and audited at the finish. Leaner, faster.',
|
||||||
|
};
|
||||||
|
const noteEl = bp.querySelector('[data-bp-note]');
|
||||||
|
let current = bp.dataset.default;
|
||||||
|
const set = (value) => {
|
||||||
|
current = value;
|
||||||
|
bp.querySelectorAll('.bp-opt').forEach(b => {
|
||||||
|
const on = b.dataset.bp === value;
|
||||||
|
b.classList.toggle('active', on);
|
||||||
|
b.setAttribute('aria-checked', String(on));
|
||||||
|
});
|
||||||
|
if (noteEl) noteEl.textContent = notes[value];
|
||||||
|
};
|
||||||
|
set(current);
|
||||||
|
const enterComp = () => {
|
||||||
|
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
|
||||||
|
const front = card.querySelector('.face.front');
|
||||||
|
if (!front || front.querySelector('.media.comp-pending') || front.querySelector('.media img.comp:not([hidden])')) return;
|
||||||
|
const m = document.createElement('div');
|
||||||
|
m.className = 'media comp-pending';
|
||||||
|
m.dataset.comp = card.dataset.compSlot;
|
||||||
|
m.innerHTML = '<div class="shimmer"><span class="comp-note">rendering…</span></div><img class="comp" alt="" hidden>';
|
||||||
|
const wireEl = front.querySelector('.media.wire');
|
||||||
|
if (wireEl) { wireEl.hidden = true; front.insertBefore(m, wireEl); }
|
||||||
|
else { front.classList.remove('text-only'); front.insertBefore(m, front.querySelector('.body')); }
|
||||||
|
pollComp(m);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const exitComp = () => {
|
||||||
|
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
|
||||||
|
const front = card.querySelector('.face.front');
|
||||||
|
const pending = front?.querySelector('.media.comp-pending');
|
||||||
|
if (!pending) return; // landed comps stay; they exist either way
|
||||||
|
pending.remove();
|
||||||
|
const wireEl = front.querySelector('.media.wire');
|
||||||
|
if (wireEl) wireEl.hidden = false;
|
||||||
|
else if (!front.querySelector('.media')) front.classList.add('text-only');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const apply = (value) => {
|
||||||
|
set(value);
|
||||||
|
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
|
||||||
|
if (value === 'comp') enterComp(); else exitComp();
|
||||||
|
};
|
||||||
|
// Flipping to comp starts real generation, so it confirms first; the
|
||||||
|
// flip back is free and applies immediately.
|
||||||
|
const confirm = document.getElementById('bp-confirm');
|
||||||
|
const closeConfirm = () => { confirm.classList.remove('open'); confirm.hidden = true; };
|
||||||
|
confirm.querySelector('[data-confirm]').addEventListener('click', () => { closeConfirm(); apply('comp'); });
|
||||||
|
confirm.querySelector('[data-cancel]').addEventListener('click', closeConfirm);
|
||||||
|
confirm.addEventListener('click', (e) => { if (e.target === confirm) closeConfirm(); });
|
||||||
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !confirm.hidden) closeConfirm(); });
|
||||||
|
bp.querySelectorAll('.bp-opt').forEach(b => b.addEventListener('click', () => {
|
||||||
|
const value = b.dataset.bp;
|
||||||
|
if (value === current) return;
|
||||||
|
if (value === 'comp') {
|
||||||
|
confirm.hidden = false;
|
||||||
|
requestAnimationFrame(() => confirm.classList.add('open'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apply(value);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// A declared image that never loads (missing catalog asset, offline shell)
|
// 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
|
// must not sit as a dark void: the slot collapses to the card's own
|
||||||
@@ -742,7 +1157,7 @@ function page() {
|
|||||||
// slots are excluded; their polling owns the wait.
|
// slots are excluded; their polling owns the wait.
|
||||||
const artFailed = (img) => {
|
const artFailed = (img) => {
|
||||||
const m = img.closest('.media');
|
const m = img.closest('.media');
|
||||||
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
|
if (!m || m.classList.contains('comp-pending') || m.classList.contains('unavailable')) return;
|
||||||
m.classList.add('unavailable');
|
m.classList.add('unavailable');
|
||||||
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
|
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(', ') + ')';
|
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(', ') + ')';
|
||||||
@@ -755,19 +1170,19 @@ function page() {
|
|||||||
label.textContent = 'artwork unavailable';
|
label.textContent = 'artwork unavailable';
|
||||||
m.appendChild(label);
|
m.appendChild(label);
|
||||||
};
|
};
|
||||||
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
|
document.querySelectorAll('.media:not(.comp-pending) > img').forEach(img => {
|
||||||
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
||||||
else img.addEventListener('error', () => artFailed(img), { once: true });
|
else img.addEventListener('error', () => artFailed(img), { once: true });
|
||||||
});
|
});
|
||||||
// A broken inspiration PIP just leaves; nothing depends on it.
|
// A broken inspiration PIP or thumb just leaves; nothing depends on it.
|
||||||
document.querySelectorAll('.pip img').forEach(img => {
|
document.querySelectorAll('.pip img, .inspo img').forEach(img => {
|
||||||
const gone = () => img.closest('.pip')?.remove();
|
const gone = () => img.closest('.pip, .inspo')?.remove();
|
||||||
if (img.complete && img.naturalWidth === 0) gone();
|
if (img.complete && img.naturalWidth === 0) gone();
|
||||||
else img.addEventListener('error', gone, { once: true });
|
else img.addEventListener('error', gone, { once: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Inspiration PIP opens the full catalog card in the lightbox.
|
// Inspiration PIP or body thumb opens the full catalog card in the lightbox.
|
||||||
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
|
document.querySelectorAll('.pip, .inspo').forEach(p => p.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const img = p.querySelector('img');
|
const img = p.querySelector('img');
|
||||||
if (!img) return;
|
if (!img) return;
|
||||||
@@ -814,7 +1229,7 @@ function page() {
|
|||||||
const ambient = document.getElementById('ambient');
|
const ambient = document.getElementById('ambient');
|
||||||
document.querySelectorAll('.card').forEach(card => {
|
document.querySelectorAll('.card').forEach(card => {
|
||||||
card.addEventListener('mouseenter', () => {
|
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;
|
if (!art || !art.getAttribute('src')) return;
|
||||||
ambient.style.backgroundImage = 'url("' + art.getAttribute('src') + '")'; ambient.style.opacity = '1';
|
ambient.style.backgroundImage = 'url("' + art.getAttribute('src') + '")'; ambient.style.opacity = '1';
|
||||||
});
|
});
|
||||||
@@ -862,8 +1277,11 @@ function page() {
|
|||||||
lightbox.addEventListener('click', closeLightbox);
|
lightbox.addEventListener('click', closeLightbox);
|
||||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
||||||
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
|
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
|
||||||
document.getElementById('reroll')?.addEventListener('click', async () => {
|
const dealAgain = async (register) => {
|
||||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
|
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 grid = document.querySelector('.grid');
|
||||||
const cardsNow = [...grid.querySelectorAll('.card')];
|
const cardsNow = [...grid.querySelectorAll('.card')];
|
||||||
const g = grid.getBoundingClientRect();
|
const g = grid.getBoundingClientRect();
|
||||||
@@ -880,14 +1298,17 @@ function page() {
|
|||||||
}
|
}
|
||||||
const cardHeight = cardsNow[0] ? cardsNow[0].getBoundingClientRect().height : 0;
|
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('');
|
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 () => {
|
const poll = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const status = await (await fetch('/next-status')).json();
|
const status = await (await fetch('/next-status')).json();
|
||||||
if (status.ready) { clearInterval(poll); location.reload(); }
|
if (status.ready) { clearInterval(poll); location.reload(); }
|
||||||
} catch { /* server briefly busy */ }
|
} catch { /* server briefly busy */ }
|
||||||
}, 1200);
|
}, 1200);
|
||||||
});
|
}
|
||||||
|
document.getElementById('reroll')?.addEventListener('click', () => dealAgain());
|
||||||
|
document.getElementById('reroll-safer')?.addEventListener('click', () => dealAgain('safer'));
|
||||||
|
document.getElementById('reroll-bolder')?.addEventListener('click', () => dealAgain('bolder'));
|
||||||
</script>`;
|
</script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -935,6 +1356,26 @@ const server = http.createServer((req, res) => {
|
|||||||
fs.createReadStream(abs).pipe(res);
|
fs.createReadStream(abs).pipe(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/build-path') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk) => { body += chunk; });
|
||||||
|
req.on('end', () => {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end('{"ok":true}');
|
||||||
|
let value = null;
|
||||||
|
try { value = JSON.parse(body).value; } catch { /* ignore */ }
|
||||||
|
if (value !== 'comp' && value !== 'code') return;
|
||||||
|
const wasComp = liveBuildPath === 'comp';
|
||||||
|
liveBuildPath = value;
|
||||||
|
// Only a flip TO comp needs the agent mid-round: comps must start
|
||||||
|
// rendering into the declared slots. The reverse is free.
|
||||||
|
if (detachedKey && value === 'comp' && !wasComp) {
|
||||||
|
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (req.method === 'POST' && req.url === '/answer') {
|
if (req.method === 'POST' && req.url === '/answer') {
|
||||||
let body = '';
|
let body = '';
|
||||||
req.on('data', (chunk) => { body += chunk; });
|
req.on('data', (chunk) => { body += chunk; });
|
||||||
@@ -944,22 +1385,30 @@ const server = http.createServer((req, res) => {
|
|||||||
let parsed = {};
|
let parsed = {};
|
||||||
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
|
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
|
||||||
const chosen = options.find((o) => o.id === parsed.optionId);
|
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({
|
const answer = JSON.stringify({
|
||||||
optionId: parsed.optionId ?? null,
|
optionId: parsed.optionId ?? null,
|
||||||
steer: parsed.steer ?? '',
|
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?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||||
...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
|
...((chosen?.comp ?? chosen?.sketch) ? { comp: chosen.comp ?? chosen.sketch } : {}),
|
||||||
|
...(liveBuildPath && !isReroll ? { buildPath: liveBuildPath, buildPathFlipped: liveBuildPath !== (buildPathDefault?.value ?? null) } : {}),
|
||||||
});
|
});
|
||||||
const isReroll = parsed.optionId === 'reroll';
|
|
||||||
if (detachedKey) {
|
if (detachedKey) {
|
||||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||||
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
|
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
|
||||||
} else {
|
} else {
|
||||||
printAnswer(answer);
|
printAnswer(answer);
|
||||||
}
|
}
|
||||||
// A re-roll in detached mode keeps the table open: the client shows a
|
// A re-roll or followup pick in detached mode keeps the table open: the
|
||||||
// loading hand and reloads when --update delivers the next round.
|
// client shows a loading hand and reloads when --update delivers the
|
||||||
if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150);
|
// next round.
|
||||||
|
if (!((isReroll || followupOpen) && detachedKey)) setTimeout(() => process.exit(0), 150);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
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 `comp` 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 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. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||||
|
|
||||||
## Input Contract
|
## Input Contract
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
|
|||||||
|
|
||||||
## Input Contract
|
## 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
|
## 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.
|
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. 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.
|
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.
|
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.
|
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.
|
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
|
## 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.
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ This skill gives you the tools and permission to create design that earns to be
|
|||||||
Core principles:
|
Core principles:
|
||||||
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
|
- 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.
|
- 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
|
## 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.
|
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.
|
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.
|
- **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.
|
- **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.
|
- **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.
|
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
|
## Verify
|
||||||
|
|
||||||
- The focal motion is specific to the selected world and surface.
|
- The focal motion is specific to the selected world and surface.
|
||||||
- Every supporting animation explains feedback, state, or relationship.
|
- Every supporting animation explains feedback, state, or relationship.
|
||||||
- Interruption and repeated use behave correctly.
|
- Interruption and repeated use behave correctly.
|
||||||
- Desktop, mobile, and keyboard paths remain usable.
|
- 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.
|
- Expensive effects stay smooth on the target device.
|
||||||
- Removing an animation would lose meaning or authored character, not merely decoration.
|
- Removing an animation would lose meaning or authored character, not merely decoration.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
> **Additional context needed**: which section is the target, and what must stay untouched.
|
> **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.
|
"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
|
## Scope is sovereign
|
||||||
|
|
||||||
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, stop and STOP and call the AskUserQuestion tool to clarify. before expanding it, naming the exact addition and the job it would do.
|
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, do not expand it on your own. STOP and call the AskUserQuestion tool to clarify. Name the exact addition and the job it would do.
|
||||||
|
|
||||||
## Why it reads flat
|
## Why it reads flat
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ Resolve one stable target, run two independent assessments, synthesize a design
|
|||||||
- Viewable targets require browser inspection when available.
|
- Viewable targets require browser inspection when available.
|
||||||
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
|
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
|
||||||
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
|
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
|
||||||
|
- The question is the LAST thing in the response. Write the entire report out first, then ask; nothing follows the question. Prose emitted after a structured question is withheld until the user answers it, so a report written after the question reads as if the critique never ran.
|
||||||
|
- A run that ends with neither the targeted questions nor a literal `Questions skipped: <reason>` line is an incomplete run. The report is not the finish; the close is.
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
@@ -172,6 +174,14 @@ Provocative questions that might unlock better solutions:
|
|||||||
- Prioritize ruthlessly. If everything is important, nothing is.
|
- Prioritize ruthlessly. If everything is important, nothing is.
|
||||||
- Don't soften criticism. Developers need honest feedback to ship great design.
|
- Don't soften criticism. Developers need honest feedback to ship great design.
|
||||||
|
|
||||||
|
### Deliver the Report
|
||||||
|
|
||||||
|
Write the full report into the chat response now, before any persistence work. This is the deliverable; everything below it is bookkeeping.
|
||||||
|
|
||||||
|
Do this first because the alternative is the most common way this command fails: the report gets composed once, straight into the persistence heredoc, and the run ends with a perfect archive nobody has read. Composing it into a file is not delivering it. If the report exists only in `.impeccable/critique/`, the run produced nothing.
|
||||||
|
|
||||||
|
Persistence is not the end of the run. After it, the response continues with the trend line and the close.
|
||||||
|
|
||||||
### Persist the Snapshot
|
### Persist the Snapshot
|
||||||
|
|
||||||
Once the report above is finalized, write it to `.impeccable/critique/` so the user can refer back, and so `/impeccable polish` can pick up the priority issues without a copy-paste.
|
Once the report above is finalized, write it to `.impeccable/critique/` so the user can refer back, and so `/impeccable polish` can pick up the priority issues without a copy-paste.
|
||||||
@@ -180,6 +190,8 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
|||||||
|
|
||||||
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, design-specificity verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
|
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, design-specificity verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
|
||||||
|
|
||||||
|
This is a copy of the report you already delivered above, for later commands to read. It is not delivery. If you find yourself composing the report for the first time inside this heredoc, you have skipped Deliver the Report; go back and send it.
|
||||||
|
|
||||||
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
|
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
|
||||||
```bash
|
```bash
|
||||||
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
|
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
|
||||||
@@ -204,12 +216,16 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
|||||||
|
|
||||||
If this is the first run for the slug, the trend is just one score; say so: "First run for this target, no trend yet."
|
If this is the first run for the slug, the trend is just one score; say so: "First run for this target, no trend yet."
|
||||||
|
|
||||||
|
6. **Close the run.** Go to Ask the User below and emit the questions, or the `Questions skipped: <reason>` line when the count allows it. The run is not complete until you do. Persistence is bookkeeping and cleanup is not an ending; stopping here leaves the user with a report and no way forward, and leaves `/impeccable polish` with no priorities to inherit.
|
||||||
|
|
||||||
This is fire-and-forget. Do not show the user the helper's JSON output; only the human-readable trend line and the written path. Failures here should not block the rest of the flow; print the error and move on.
|
This is fire-and-forget. Do not show the user the helper's JSON output; only the human-readable trend line and the written path. Failures here should not block the rest of the flow; print the error and move on.
|
||||||
|
|
||||||
### Ask the User
|
### Ask the User
|
||||||
|
|
||||||
**After presenting findings**, use targeted questions based on what was actually found. STOP and call the AskUserQuestion tool to clarify. These answers will shape the action plan.
|
**After presenting findings**, use targeted questions based on what was actually found. STOP and call the AskUserQuestion tool to clarify. These answers will shape the action plan.
|
||||||
|
|
||||||
|
Ask in the same message that carries the report, with the report written out first and the question last. Do not split the two across turns: a turn that ends on the report is a turn that ends, and the questions never arrive. Order within the message is what matters, because prose emitted after a structured question is withheld until the user answers.
|
||||||
|
|
||||||
Ask questions along these lines (adapt to the specific findings; do NOT ask generic questions):
|
Ask questions along these lines (adapt to the specific findings; do NOT ask generic questions):
|
||||||
|
|
||||||
1. **Priority direction**: Based on the issues found, ask which category matters most to the user right now. For example: "I found problems with visual hierarchy, color usage, and information overload. Which area should we tackle first?" Offer the top 2-3 issue categories as options.
|
1. **Priority direction**: Based on the issues found, ask which category matters most to the user right now. For example: "I found problems with visual hierarchy, color usage, and information overload. Which area should we tackle first?" Offer the top 2-3 issue categories as options.
|
||||||
@@ -224,7 +240,9 @@ Ask questions along these lines (adapt to the specific findings; do NOT ask gene
|
|||||||
- Every question must reference specific findings from the report. Never ask generic "who is your audience?" questions.
|
- Every question must reference specific findings from the report. Never ask generic "who is your audience?" questions.
|
||||||
- Keep it to 2-4 questions maximum. Respect the user's time.
|
- Keep it to 2-4 questions maximum. Respect the user's time.
|
||||||
- Offer concrete options, not open-ended prompts.
|
- Offer concrete options, not open-ended prompts.
|
||||||
- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Recommended Actions.
|
- Skipping is allowed only when the report listed **fewer than 3 Priority Issues**. Count them; do not judge the findings "straightforward" by feel. At 3 or more, the questions are required.
|
||||||
|
|
||||||
|
**Final-question gate.** The user-visible response must either include the targeted questions or carry the literal line `Questions skipped: <reason>` naming the count that permitted the skip. Each question must include 2-3 concrete answer options tied to the actual critique findings. Do not end with only open-ended questions, and do not end with neither: stopping after the report, having asked nothing and printed no skip line, is the most common way this command fails.
|
||||||
|
|
||||||
### Recommended Actions
|
### Recommended Actions
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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 `comp` 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 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. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||||
|
|
||||||
## Input Contract
|
## Input Contract
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
|
|||||||
|
|
||||||
## Input Contract
|
## 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
|
## 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.
|
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. 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.
|
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.
|
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.
|
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.
|
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
|
## 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.
|
||||||
@@ -21,7 +21,7 @@ Analyze what makes the design feel complex or cluttered:
|
|||||||
- What can be removed, hidden, or combined?
|
- What can be removed, hidden, or combined?
|
||||||
- What's the 20% that delivers 80% of value?
|
- What's the 20% that delivers 80% of value?
|
||||||
|
|
||||||
If any of these are unclear from the codebase, STOP and call the AskUserQuestion tool to clarify.
|
If any of these are unclear from the codebase, do not guess. STOP and call the AskUserQuestion tool to clarify.
|
||||||
|
|
||||||
**CRITICAL**: Simplicity is not about removing features. It's about removing obstacles between users and their goals. Every element should justify its existence.
|
**CRITICAL**: Simplicity is not about removing features. It's about removing obstacles between users and their goals. Every element should justify its existence.
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ The same restraint applies to `workspace-context-inherited`. Inheritance is a de
|
|||||||
|
|
||||||
- `workspace-platform-native-evidence` is the finding that matters most here: a workspace carrying native build files while inheriting a root record that resolves to web gets web guidance for its whole life and never loads [ios.md](ios.md) or [android.md](android.md). The repair is a child PRODUCT.md in that workspace, because one inherited record cannot hold two platforms.
|
- `workspace-platform-native-evidence` is the finding that matters most here: a workspace carrying native build files while inheriting a root record that resolves to web gets web guidance for its whole life and never loads [ios.md](ios.md) or [android.md](android.md). The repair is a child PRODUCT.md in that workspace, because one inherited record cannot hold two platforms.
|
||||||
- `config-project-roots-match-nothing` means every `projectRoots` glob missed, so the repo root is silently standing in as the active project. A renamed workspace directory is the usual cause. Report the patterns and ask which directories they should name.
|
- `config-project-roots-match-nothing` means every `projectRoots` glob missed, so the repo root is silently standing in as the active project. A renamed workspace directory is the usual cause. Report the patterns and ask which directories they should name.
|
||||||
|
- `config-invalid-build-path` and `config-build-path-unset` both concern one key, `buildPath` in `.impeccable/config.json` (or the gitignored `.impeccable/config.local.json`, which wins for that developer). It holds `comp` or `code` and sets whether new surfaces are built from a generated comp or straight in code. An unread value does not fall back to the opposite path, so a project meaning `code` has been building comp-led; report the exact value. The unset finding fires only where a project has done direction work and never recorded a preference, and the offer belongs in it only when image generation exists in your tool surface. Without image generation there is nothing to choose and nothing to say.
|
||||||
- Use the `workspaces` table to show the user which apps carry their own context, which inherit, and which have none, before proposing any change.
|
- Use the `workspaces` table to show the user which apps carry their own context, which inherit, and which have none, before proposing any change.
|
||||||
|
|
||||||
## Opting out of the boot check
|
## Opting out of the boot check
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Omit irrelevant sections rather than filling them with invented rules. Put respo
|
|||||||
- An existing `DESIGN.md` is stale (the design has drifted).
|
- An existing `DESIGN.md` is stale (the design has drifted).
|
||||||
- Before a large redesign, to capture the current state as a reference.
|
- Before a large redesign, to capture the current state as a reference.
|
||||||
|
|
||||||
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file and STOP and call the AskUserQuestion tool to clarify. whether to refresh, overwrite, or merge.
|
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file first. STOP and call the AskUserQuestion tool to clarify. The choice is refresh, overwrite, or merge.
|
||||||
|
|
||||||
## Two paths
|
## Two paths
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Identify reusable patterns, components, and design tokens, then extract and cons
|
|||||||
|
|
||||||
Find the design system, component library, or shared UI directory. Understand its structure: component organization, naming conventions, design token structure, import/export conventions.
|
Find the design system, component library, or shared UI directory. Understand its structure: component organization, naming conventions, design token structure, import/export conventions.
|
||||||
|
|
||||||
**CRITICAL**: If no design system exists, STOP and call the AskUserQuestion tool to clarify. before creating one. Understand the preferred location and structure first.
|
**CRITICAL**: If no design system exists, do not create one yet. STOP and call the AskUserQuestion tool to clarify. Understand the preferred location and structure first.
|
||||||
|
|
||||||
## Step 2: Identify Patterns
|
## Step 2: Identify Patterns
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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:
|
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.
|
- 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` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
- 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.
|
- 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.
|
- 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.
|
- 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"
|
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
|
```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:
|
Example whole-rule font exception:
|
||||||
|
|||||||
@@ -107,9 +107,11 @@ When the platform you just recorded is `ios`, `android`, or `adaptive`, load [io
|
|||||||
|
|
||||||
Before loading new-work or resuming shape/build, verify that PRODUCT.md exists at the resolved path and contains the confirmed product record. If the file is absent, init is incomplete. Do not substitute interview notes, a planning packet, or later design prose for the file.
|
Before loading new-work or resuming shape/build, verify that PRODUCT.md exists at the resolved path and contains the confirmed product record. If the file is absent, init is incomplete. Do not substitute interview notes, a planning packet, or later design prose for the file.
|
||||||
|
|
||||||
## Step 5: Configure live mode when useful
|
## Step 5: Record workflow defaults
|
||||||
|
|
||||||
Skip native or non-runnable projects and leave existing config untouched. Otherwise follow [live.md](live.md)'s first-time setup. Any CSP source edit still requires its stated consent.
|
When image generation is available (context.mjs reports it) and no `buildPath` is recorded yet, ask once how new surfaces should be built, stated as the trade it is: **comp-first** (an image sets the bar before any code; bolder composition, slower, and the build must match the image) or **code-first** (build directly; the ambition is written into the direction contract and audited at the finish; leaner, faster). Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, merging with the keys already there. A value already recorded in `.impeccable/config.json` or the gitignored `.impeccable/config.local.json` is a confirmed answer: on a re-run, honor it in silence rather than asking again. This is a default, not a lock: the decision page renders a toggle whose flip binds a single session and is never written back. Without image generation there is no choice to record; code-first is the only path.
|
||||||
|
|
||||||
|
Then configure live mode when useful: skip native or non-runnable projects and leave existing config untouched. Otherwise follow [live.md](live.md)'s first-time setup. Any CSP source edit still requires its stated consent.
|
||||||
|
|
||||||
## Step 6: Wrap up or resume
|
## Step 6: Wrap up or resume
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -36,19 +36,21 @@ Keep the visual system fixed. Derive five to seven materially different structur
|
|||||||
|
|
||||||
`node .claude/skills/impeccable/scripts/concept-seed.mjs --scope surface --mode <mode>`
|
`node .claude/skills/impeccable/scripts/concept-seed.mjs --scope surface --mode <mode>`
|
||||||
|
|
||||||
The script assigns which structure gets built; your top-ranked structure is what every run would ship, so the dice come from outside. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
The script deals three of your structures to the table; the dice decide which three reach the user, so the ranking rut stays broken while the user still holds a real choice. Present the three dealt structures on the decision page as full cards of equal salience, the dealt lead carrying kicker THE ROLL, with steer and re-roll; the user locks one in. No canon card and no pick card at surface scope: the world is settled, so every card visualizes composition, not identity. With image generation available and a comp-led default (the build-path paragraph below: `.impeccable/config.json`, the toggle handles the exception), each card declares a `comp` under `.impeccable/mocks/decision/`, generated after serving in reading order under the comp discipline in [visualize.md](visualize.md); anchor each of these comps on the established identity by passing a captured screenshot of a representative existing page as a reference image (the harness image tool's input image, or `generate-image.mjs --ref`) beside a prompt that leads with the new surface's structure and names DESIGN.md's palette, type, and component character, because a prose paraphrase of a design system drifts where a pixel reference does not. Without image generation, or under a code-led default, each card instead carries a `wireframe` layout schematic (see `serve-question.mjs --schema`) that the page draws itself. Locking a card is the approval and sets the build path: a locked comp builds comp-led with that comp as the approved comp, discharging [visualize.md](visualize.md)'s three-option round with no second approval point; a locked wireframe builds code-led, its ambition carried by the direction contract. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
||||||
|
|
||||||
### Create or replace the visual world
|
### Create or replace the visual world
|
||||||
|
|
||||||
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.
|
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.
|
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.
|
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.
|
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: 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.
|
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 IMPECCABLE’S 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 `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (the build-path paragraph below owns the details); 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. The fallback to the structured question tool is never yours to predict: run the script, and only exit code 2 from starting it routes the decision there; treat that exit as the 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. 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 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 `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, 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; visualize.md's self-checks bind decision comps identically. Generation takes the same time at any fidelity, so an unfinished draft pays draft 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 execution contract, comp-led or code-led, is a workflow preference, not a per-surface decision, so no round asks it: the recorded default rides every round and the page's toggle handles the exception. Read the default from `.impeccable/config.json` (`buildPath`), with the gitignored `.impeccable/config.local.json` winning where one machine differs from the team's committed value; with neither, comp-led is the default whenever image generation exists. Author every direction and surface payload with `buildPath: { "value": <default>, "toggle": true }`; the page renders a footer toggle with the trade stated beside it, and the ANSWER returns `buildPath` plus `buildPathFlipped`. A flipped value binds that session only and is never written back, with one exception, and it is the only thing inside a round that earns a question about this preference (init records it up front on projects that get the chance): when `buildPathFlipped` comes back true on a project that records no `buildPath` at all, ask once after the round closes whether to keep it as the standing default. Either answer ends in a write to `.impeccable/config.json`; the answer picks the value, never whether to record one. Yes writes the flipped value, and "no, just this once" writes the value they flipped away from, which is the standing default they just confirmed by declining. Ask on the flip and never on the untouched default, because a user who left the toggle alone has told you nothing. A declined offer nothing writes down is an offer the next session makes again. When the user asks in words to change the standing default, update the file without asking. **Comp-led**: the chosen card's comp is law, generated before building when it does not exist yet, and the finish review audits the build against it; boldest composition on the table, fix rounds expected; comp-led 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. A code-led round still declares each card's comp path as a flip reserve: when the user flips the toggle to comp mid-round, `--wait` returns once with BUILD PATH FLIPPED while the page shimmers the slots; generate each open card's comp into its declared path then, lead first, and wait again. The flip back is free, and a comp that already rendered rides at the finish review as the critique reference. Without image generation there is no toggle and no choice: code-led is the only path, stated in one line rather than asked. The old two-card execution-contract round is retired; `followup: true` remains the general mechanism for delivering any later round over the same table via `--update`.
|
||||||
|
|
||||||
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.
|
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,7 +82,7 @@ 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.
|
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.
|
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
|
||||||
|
|
||||||
@@ -103,8 +105,8 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
|||||||
|
|
||||||
## 7. Inspect and finish
|
## 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. 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. 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.
|
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.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Push an interface past conventional limits. This isn't just about visual effects
|
|||||||
This command has the highest potential to misfire. Do NOT jump straight into implementation. You MUST:
|
This command has the highest potential to misfire. Do NOT jump straight into implementation. You MUST:
|
||||||
|
|
||||||
1. **Think through 2-3 different directions**: consider different techniques, levels of ambition, and aesthetic approaches. For each direction, briefly describe what the result would look and feel like.
|
1. **Think through 2-3 different directions**: consider different techniques, levels of ambition, and aesthetic approaches. For each direction, briefly describe what the result would look and feel like.
|
||||||
2. **STOP and call the AskUserQuestion tool to clarify.** to present these directions and get the user's pick before writing any code. Explain trade-offs (browser support, performance cost, complexity).
|
2. **Get the user's pick before writing any code.** STOP and call the AskUserQuestion tool to clarify. Carry each direction's description and its trade-offs (browser support, performance cost, complexity) inside the option itself, so the user is choosing between things they can read. A structured question blocks the message it rides in until the user answers, so directions written alongside the question stay invisible while the user is being asked to choose between them.
|
||||||
3. Only proceed with the direction the user confirms.
|
3. Only proceed with the direction the user confirms.
|
||||||
|
|
||||||
Skipping this step risks building something embarrassing that needs to be thrown away.
|
Skipping this step risks building something embarrassing that needs to be thrown away.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Fix the cause at the narrowest correct level. Ask when a binding system principl
|
|||||||
|
|
||||||
## 2. Gather the evidence
|
## 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;
|
- whether the path is functionally complete;
|
||||||
- the intended quality bar and time available;
|
- 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:
|
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;
|
- loading, empty, error, success, disabled, long-content, and missing-content states;
|
||||||
- zoom, contrast, focus, semantics, and screen-reader names;
|
- 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.
|
- 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.
|
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.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Analyze what makes the design feel too intense:
|
|||||||
- What's working? (Don't throw away good ideas)
|
- What's working? (Don't throw away good ideas)
|
||||||
- What's the core message? (Preserve what matters)
|
- What's the core message? (Preserve what matters)
|
||||||
|
|
||||||
If any of these are unclear from the codebase, STOP and call the AskUserQuestion tool to clarify.
|
If any of these are unclear from the codebase, do not guess. STOP and call the AskUserQuestion tool to clarify.
|
||||||
|
|
||||||
**CRITICAL**: "Quieter" doesn't mean boring or generic. It means refined and easier on the eyes. Think luxury, not laziness.
|
**CRITICAL**: "Quieter" doesn't mean boring or generic. It means refined and easier on the eyes. Think luxury, not laziness.
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
# Visualize: Direction Comps & Asset Production
|
# 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. A surface-scope structure round that already put three visualized cards before the user (new-work.md, established world) has discharged this round: the locked card’s comp is the approved comp, so record the approval and continue at After approval; generate nothing new.
|
||||||
|
|
||||||
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.
|
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
|
## 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. On an established world, anchor every comp on the real identity: capture a screenshot of a representative existing page and pass it as a reference image (the harness image tool’s input image, or `generate-image.mjs --ref`); the prompt then leads with the new surface’s structure while the reference carries palette, type, and component character, because DESIGN.md words alone drift where a pixel reference does not. Name what the reference contributes and what it must not: chrome, palette, type, and component character carry over; the reference page’s own content does not, so a banner, hero, or card lifted verbatim from the reference is the reference leaking, not fidelity. 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.
|
- 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.
|
||||||
|
- The inverse is also a failure: a surface with none of its subject in it. The subject appears as the content the regions exist to hold; the world dresses the frame and never displaces what the frame exists to show. The deletion usually rides in on the prompt's exclusion list, so exclusions bind invented claims, and a medium ban belongs to the committed imagery stance, never to caution. Before accepting a render, point at the subject: a render that depicts everything about the world and nothing of the subject fails however faithful its atmosphere, so regenerate with the subject's content named region by region.
|
||||||
|
- A comp is judged as the shipped screen: the visitor's job must be readable from the image alone. Name the surface's mode from the render with no caption; a render whose mode cannot be read back is art direction without a surface, so regenerate with the visitor's job as the prompt's spine.
|
||||||
|
- Commitment is depth, not coverage. The world enters through one dominant move plus the material, type, and spacing that support it, and the remaining regions hold still so that move can be read; a region that simply does its job in the world's own grammar carries the direction further than a region performing the concept. The check cuts competition, never content: a quieted region keeps its information and stops performing. Where the direction names a focal moment, a second element competing with it at the same scale means the comp is shouting; where it names none, several regions performing the concept at once is the same shout. Regenerate keeping the strongest move and quieting the rest. Busy is louder, not bolder.
|
||||||
- When the user shortlisted multiple concepts, spread the three across them.
|
- When the user shortlisted multiple concepts, spread the three across them.
|
||||||
- When one direction is committed, vary the structural uncertainty an image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
- When one direction is committed, vary the structural uncertainty an image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||||
- Show enough beyond the opening moment to prove the concept can govern the whole requested surface.
|
- Show enough beyond the opening moment to prove the concept can govern the whole requested surface.
|
||||||
@@ -18,11 +21,11 @@ Treat each comp as a direction test, not a screenshot specification. Core UI tex
|
|||||||
|
|
||||||
## One approval point
|
## One approval point
|
||||||
|
|
||||||
Show the three together: in the harness when it can display images, otherwise on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero). Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
Show the three together on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero), or in the harness only when it renders images inline; a text-only surface does not count as display. Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||||
|
|
||||||
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,16 @@
|
|||||||
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
|
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
|
||||||
* fresh assigned index, challengers, and compositions. One base key therefore
|
* fresh assigned index, challengers, and compositions. One base key therefore
|
||||||
* reproduces the entire chain of rounds.
|
* 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
|
* - RATINGS: the reviewer's approval ratings weight the challenger draw
|
||||||
* (3-star doubles the odds, 1-star sits out); the approved pool itself
|
* (3-star doubles the odds, 1-star sits out); the approved pool itself
|
||||||
* is unchanged.
|
* is unchanged.
|
||||||
@@ -41,7 +51,9 @@
|
|||||||
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
|
* 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 --candidate-count 6
|
||||||
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
|
* 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
|
* --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
|
* 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
|
* Challenger data resolves in order: a local catalog directory (the private
|
||||||
* service repo, evals, and tests set IMPECCABLE_CATALOG_DIR), then the roll
|
* 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
|
* API at impeccable.style, then a degraded assignment-only seed when both are
|
||||||
* unavailable. --chosen sends the anonymous choice ping for API-dealt rolls;
|
* unavailable. The anonymous choice ping fires once per resolved attended
|
||||||
* DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables it.
|
* 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:
|
* Env vars:
|
||||||
* IMPECCABLE_CONCEPT_SEED — same as --from; for reproducible eval runs.
|
* 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);
|
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.
|
// Fire-and-forget; never fails the caller.
|
||||||
export async function pingChosen({ chosenId, key, scope, mode }) {
|
const PING_KINDS = new Set(['assigned', 'pick', 'challenger', 'canon']);
|
||||||
if (telemetryDisabled() || !chosenId) return false;
|
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 controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_BASE}/chosen`, {
|
await fetch(`${API_BASE}/chosen`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -260,6 +295,7 @@ export function renderConceptSeed({
|
|||||||
scope = 'surface',
|
scope = 'surface',
|
||||||
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
|
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
|
||||||
reroll = 0,
|
reroll = 0,
|
||||||
|
register = null,
|
||||||
mode = null,
|
mode = null,
|
||||||
grain = null,
|
grain = null,
|
||||||
platform = null,
|
platform = null,
|
||||||
@@ -273,6 +309,15 @@ export function renderConceptSeed({
|
|||||||
if (!Number.isInteger(reroll) || reroll < 0) {
|
if (!Number.isInteger(reroll) || reroll < 0) {
|
||||||
throw new Error('concept-seed: --reroll must be a non-negative integer');
|
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)) {
|
if (mode !== null && !SEED_MODES.has(mode)) {
|
||||||
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
|
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
|
||||||
}
|
}
|
||||||
@@ -293,6 +338,20 @@ export function renderConceptSeed({
|
|||||||
};
|
};
|
||||||
const indexSalt = reroll === 0 ? 'index' : `index:reroll-${reroll}`;
|
const indexSalt = reroll === 0 ? 'index' : `index:reroll-${reroll}`;
|
||||||
const buildIndex = 3 + Math.floor(unit(indexSalt) * (candidateCount - 2)); // 3..candidateCount
|
const buildIndex = 3 + Math.floor(unit(indexSalt) * (candidateCount - 2)); // 3..candidateCount
|
||||||
|
// Surface scope deals a hand of three grounded structures: one card is not
|
||||||
|
// a choice, and the full ranked list would hand selection back to the
|
||||||
|
// model's taste. The dice pick all three; the primary index leads. The
|
||||||
|
// no-lineup rule stays direction-only, where it was written for worlds.
|
||||||
|
const dealtIndices = [buildIndex];
|
||||||
|
for (let draw = 0; scope === 'surface' && dealtIndices.length < Math.min(3, candidateCount); draw += 1) {
|
||||||
|
const idx = 1 + Math.floor(unit(`${indexSalt}:deal-${draw}`) * candidateCount);
|
||||||
|
if (!dealtIndices.includes(idx)) dealtIndices.push(idx);
|
||||||
|
if (draw > 64) { // hash repeats cannot stall the deal
|
||||||
|
for (let fill = 1; dealtIndices.length < Math.min(3, candidateCount); fill += 1) {
|
||||||
|
if (!dealtIndices.includes(fill)) dealtIndices.push(fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Local catalog first (private repo, evals, tests), then the roll API,
|
// Local catalog first (private repo, evals, tests), then the roll API,
|
||||||
// then a degraded assignment-only seed. The assigned index is pure local
|
// then a degraded assignment-only seed. The assigned index is pure local
|
||||||
@@ -326,6 +385,7 @@ export function renderConceptSeed({
|
|||||||
scope,
|
scope,
|
||||||
key,
|
key,
|
||||||
reroll,
|
reroll,
|
||||||
|
register,
|
||||||
mode,
|
mode,
|
||||||
grain,
|
grain,
|
||||||
platform,
|
platform,
|
||||||
@@ -357,16 +417,32 @@ export function renderConceptSeed({
|
|||||||
survive the current task plus navigation, quiet and dense content,
|
survive the current task plus navigation, quiet and dense content,
|
||||||
interaction and state, and a substantially different future surface. In an
|
interaction and state, and a substantially different future surface. In an
|
||||||
attended run, present the assigned direction fully committed and offer
|
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 IMPECCABLE’S 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
|
on named factual grounds, when the assignment cannot carry the product's
|
||||||
truth or task; taste is never grounds.`
|
truth or task; taste is never grounds.`
|
||||||
: `After ordering the task's grounded structural candidates by resonance,
|
: `After ordering the task's grounded structural candidates by resonance,
|
||||||
build candidate ${buildIndex} of your own grounded list; the assignment never
|
deal candidates ${dealtIndices.join(', ')} of your own grounded list to the
|
||||||
points at a challenger. The assignment is the roll, not a suggestion.
|
table; index ${buildIndex} leads, and the deal never points at a challenger.
|
||||||
In an attended run, present the assigned structure and offer re-roll; never
|
The deal is the roll, not a suggestion: the dice decide which structures
|
||||||
present a ranked lineup to choose from. Re-roll yourself only when the
|
reach the user, so the ranking rut stays broken while the user still gets a
|
||||||
assignment fails audience identification or product clarity on named
|
real choice, and the full ranked list stays yours. In an attended run,
|
||||||
factual grounds.`;
|
present the three dealt structures as full cards of equal salience, the
|
||||||
|
lead carrying kicker THE ROLL, with steer and re-roll, and let the user
|
||||||
|
lock one in; the world is already settled, so this choice is composition.
|
||||||
|
Visualize every dealt card: with image generation available and a
|
||||||
|
comp-led default (.impeccable/config.json buildPath; the page toggle
|
||||||
|
handles the exception), declare a comp per card and generate after
|
||||||
|
serving, lead first; otherwise author each card's wireframe field (see
|
||||||
|
serve-question --schema) and the page draws the schematic. Carry the
|
||||||
|
recorded default in the payload as buildPath with toggle: true. Locking a card
|
||||||
|
approves its comp: a surface round that put three visualized structures on
|
||||||
|
the table replaces the three-option comp round in visualize.md. Re-roll
|
||||||
|
yourself only when every dealt structure fails audience identification or
|
||||||
|
product clarity on named factual grounds.`;
|
||||||
|
|
||||||
const challengerInstruction = scope === 'direction'
|
const challengerInstruction = scope === 'direction'
|
||||||
? `Fuse each challenger before judging it: the challenger supplies the form
|
? `Fuse each challenger before judging it: the challenger supplies the form
|
||||||
@@ -374,7 +450,16 @@ export function renderConceptSeed({
|
|||||||
conflicts. Weigh the fused result against the assigned direction on exactly
|
conflicts. Weigh the fused result against the assigned direction on exactly
|
||||||
two axes, audience identification and product clarity. Losing to strong
|
two axes, audience identification and product clarity. Losing to strong
|
||||||
grounded material is a valid outcome; beating a thin or tool-monoculture
|
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
|
: `A challenger wins only when its fused result beats the grounded list on
|
||||||
audience identification and product clarity. It may change task topology or
|
audience identification and product clarity. It may change task topology or
|
||||||
interaction, but never the committed visual identity.`;
|
interaction, but never the committed visual identity.`;
|
||||||
@@ -399,8 +484,39 @@ Ambitious motion, spatial media, or interaction is welcome when it strengthens
|
|||||||
the product without weakening semantics, performance, or fallback behavior.`;
|
the product without weakening semantics, performance, or fallback behavior.`;
|
||||||
|
|
||||||
if (!data) {
|
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})
|
// A degraded roll can still serve the safer register, which needs no
|
||||||
ASSIGNED INDEX: ${buildIndex}
|
// 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}${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
|
||||||
${promotedInstruction}
|
${promotedInstruction}
|
||||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
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.
|
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||||
@@ -424,8 +540,11 @@ channel: when a browser can open, present the direction on the decision page
|
|||||||
the no-browser fallback.
|
the no-browser fallback.
|
||||||
${authorityInstruction}
|
${authorityInstruction}
|
||||||
A user- or brief-pinned decision beats the roll, always.
|
A user- or brief-pinned decision beats the roll, always.
|
||||||
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
${scope === 'direction'
|
||||||
${buildIndex} of your own grounded list; seed key ${key}.
|
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||||
|
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||||
|
: `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index
|
||||||
|
${buildIndex} leads. Present all three dealt structures; seed key ${key}.`}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,34 +590,79 @@ 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`
|
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
|
||||||
: '';
|
: '';
|
||||||
const rerollBlock = reroll > 0
|
const rerollBlock = reroll > 0
|
||||||
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
|
? `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. Derive
|
and challenger alike, is eliminated and may not return reworded.${register ? '' : ` Derive
|
||||||
genuinely new grounded candidates from unexplored angles before judging
|
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'
|
const telemetryBlock = data.source === 'api'
|
||||||
? `TELEMETRY: if the resolved direction uses one of these challengers, rerun
|
? `TELEMETRY: after the user's choice resolves, rerun this script once with
|
||||||
this script once with --chosen <challenger-id> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''}
|
--kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''},
|
||||||
after resolution. The ping is anonymous (chosen id only) and is skipped
|
adding --chosen <challenger-id> when a dealt challenger won and keeping
|
||||||
automatically when DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY is set.\n`
|
--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)
|
const assignedBlock = register === null
|
||||||
${rerollBlock}ASSIGNED INDEX: ${buildIndex}
|
? `${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
|
||||||
${promotedInstruction}
|
${promotedInstruction}
|
||||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
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.
|
the user or the brief. Never expose assignment metadata in user-facing labels.`
|
||||||
CHALLENGERS:
|
: 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')}
|
${data.challengers.map(renderChallenger).join('\n')}
|
||||||
${compositionBlock}${challengerInstruction}
|
${compositionBlock}${roundChallengerInstruction}
|
||||||
When you can view images, open the QUALITY BAR board and hero for any
|
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
|
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,
|
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.
|
never as a mockup to copy; your surface serves this product, not that render.
|
||||||
${authorityInstruction}
|
`;
|
||||||
|
const restated = register === null
|
||||||
|
? (scope === 'direction'
|
||||||
|
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||||
|
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||||
|
: `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index
|
||||||
|
${buildIndex} leads. Present all three dealt structures; 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}
|
${richnessInstruction}
|
||||||
${telemetryBlock}A user- or brief-pinned decision beats the roll, always.
|
${telemetryBlock}A user- or brief-pinned decision beats the roll, always.
|
||||||
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
${restated}
|
||||||
${buildIndex} of your own grounded list; seed key ${key}.
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,19 +671,25 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
|||||||
const fromIdx = args.indexOf('--from');
|
const fromIdx = args.indexOf('--from');
|
||||||
const scopeIdx = args.indexOf('--scope');
|
const scopeIdx = args.indexOf('--scope');
|
||||||
const rerollIdx = args.indexOf('--reroll');
|
const rerollIdx = args.indexOf('--reroll');
|
||||||
|
const registerIdx = args.indexOf('--register');
|
||||||
const modeIdx = args.indexOf('--mode');
|
const modeIdx = args.indexOf('--mode');
|
||||||
const grainIdx = args.indexOf('--grain');
|
const grainIdx = args.indexOf('--grain');
|
||||||
const platformIdx = args.indexOf('--platform');
|
const platformIdx = args.indexOf('--platform');
|
||||||
const candidateCountIdx = args.indexOf('--candidate-count');
|
const candidateCountIdx = args.indexOf('--candidate-count');
|
||||||
const chosenIdx = args.indexOf('--chosen');
|
const chosenIdx = args.indexOf('--chosen');
|
||||||
|
const kindIdx = args.indexOf('--kind');
|
||||||
try {
|
try {
|
||||||
if (chosenIdx !== -1) {
|
if (chosenIdx !== -1 || kindIdx !== -1) {
|
||||||
// Choice ping: always exits 0, telemetry must never fail a design flow.
|
// 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({
|
const sent = await pingChosen({
|
||||||
chosenId: args[chosenIdx + 1],
|
chosenId: chosenIdx !== -1 ? args[chosenIdx + 1] : undefined,
|
||||||
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
|
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
|
||||||
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
|
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
|
||||||
mode: modeIdx !== -1 ? args[modeIdx + 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');
|
process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n');
|
||||||
} else {
|
} else {
|
||||||
@@ -542,6 +712,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
|||||||
? args[fromIdx + 1]
|
? args[fromIdx + 1]
|
||||||
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
|
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
|
||||||
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
|
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
|
||||||
|
register: registerIdx !== -1 ? args[registerIdx + 1] : null,
|
||||||
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
|
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
|
||||||
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
|
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
|
||||||
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
|
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
|
||||||
@@ -553,6 +724,13 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
|||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
// A raced-out fetch may still hold a socket; exit explicitly so the CLI
|
// 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);
|
process.exit(process.exitCode ?? 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,14 +42,15 @@ function latestCritique(cwd) {
|
|||||||
if (!latest) return null;
|
if (!latest) return null;
|
||||||
const get = (key) => latest.meta[key] ?? null;
|
const get = (key) => latest.meta[key] ?? null;
|
||||||
const num = (v) => {
|
const num = (v) => {
|
||||||
|
if (v == null || (typeof v === 'string' && v.trim() === '')) return null;
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
slug: get('slug'),
|
slug: get('slug'),
|
||||||
score: num(get('score')),
|
score: num(get('total_score') ?? get('score')),
|
||||||
p0: num(get('p0')),
|
p0: num(get('p0_count') ?? get('p0')),
|
||||||
p1: num(get('p1')),
|
p1: num(get('p1_count') ?? get('p1')),
|
||||||
timestamp: get('timestamp'),
|
timestamp: get('timestamp'),
|
||||||
file: path.relative(cwd, latest.path),
|
file: path.relative(cwd, latest.path),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1013,14 +1013,22 @@ async function fetchLatestSkillVersion() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||||
|
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||||
|
// continue without waiting, so a run that could never establish agreement was
|
||||||
|
// still spelled out as the next command. The offer stays; the command leaves
|
||||||
|
// this turn entirely, because installing over the skill mid-session changes
|
||||||
|
// files the session is reading and only takes effect in the next one anyway.
|
||||||
function buildUpdateDirective(localVersion, latestVersion) {
|
function buildUpdateDirective(localVersion, latestVersion) {
|
||||||
return (
|
return (
|
||||||
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
|
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
|
||||||
`(installed v${localVersion}, latest v${latestVersion}). ` +
|
`(installed v${localVersion}, latest v${latestVersion}). ` +
|
||||||
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
|
`Mention it once, in this form: "A newer Impeccable (v${latestVersion}) is available. ` +
|
||||||
`Update now? It runs \`npx impeccable update\`." ` +
|
`Update now? It runs \`npx impeccable update\`." ` +
|
||||||
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
|
`Do not run \`npx impeccable update\` in this turn, whatever the user answers: it rewrites the skill files ` +
|
||||||
`Either way, continue the current task without waiting, and do not raise this again.`
|
`this session is reading, and the update only takes effect in the next session, so there is nothing to gain now. ` +
|
||||||
|
`Run it in a later turn, only after the user has asked for it in their own words. ` +
|
||||||
|
`Continue the current task now without waiting, and do not raise this again.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1142,6 +1150,7 @@ async function cli() {
|
|||||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||||
appendDetectorFallback(parts, ctx);
|
appendDetectorFallback(parts, ctx);
|
||||||
appendImageGenDirective(parts);
|
appendImageGenDirective(parts);
|
||||||
|
appendBuildPathDirective(parts, ctx);
|
||||||
appendAutonomyCounterDirective(parts);
|
appendAutonomyCounterDirective(parts);
|
||||||
appendSubagentAuthorizationDirective(parts);
|
appendSubagentAuthorizationDirective(parts);
|
||||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||||
@@ -1161,6 +1170,7 @@ async function cli() {
|
|||||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||||
appendDetectorFallback(parts, ctx);
|
appendDetectorFallback(parts, ctx);
|
||||||
appendImageGenDirective(parts);
|
appendImageGenDirective(parts);
|
||||||
|
appendBuildPathDirective(parts, ctx);
|
||||||
appendAutonomyCounterDirective(parts);
|
appendAutonomyCounterDirective(parts);
|
||||||
appendSubagentAuthorizationDirective(parts);
|
appendSubagentAuthorizationDirective(parts);
|
||||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||||
@@ -1269,6 +1279,53 @@ function automaticHookMode(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Build-path preference: a workflow setting (comp-led vs code-led), read here
|
||||||
|
// so every session starts knowing it without a file hunt. It rides the unified
|
||||||
|
// config beside the hook and detector settings, and the gitignored local file
|
||||||
|
// wins, because whether a machine has an image tool is a property of that
|
||||||
|
// machine, not of the team's committed default. Absence stays silent;
|
||||||
|
// new-work's own default applies, and the decision page toggle can flip the
|
||||||
|
// value for a single session.
|
||||||
|
function readBuildPathAt(root) {
|
||||||
|
let value = null;
|
||||||
|
let source = null;
|
||||||
|
for (const name of ['config.json', 'config.local.json']) {
|
||||||
|
const raw = readJson(path.join(root, '.impeccable', name));
|
||||||
|
if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') {
|
||||||
|
value = raw.buildPath;
|
||||||
|
source = `.impeccable/${name}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value ? { value, source } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roots in precedence order, nearest first: the resolved project decides, and
|
||||||
|
// the repo root is the fallback a monorepo commits once for every app in it.
|
||||||
|
// `checkBuildPathUnset` reads exactly these two, and the pair has to match:
|
||||||
|
// when they disagree the finding goes silent because a value exists while the
|
||||||
|
// directive never names it, which is the one combination nobody can debug.
|
||||||
|
//
|
||||||
|
// The invoking directory is deliberately not in the chain. With `--target`
|
||||||
|
// selecting another workspace, cwd is the caller's app, not the target's, and
|
||||||
|
// letting it rank above the repo root hands one workspace another's workflow.
|
||||||
|
// It stands in only when no project resolved at all.
|
||||||
|
function appendBuildPathDirective(parts, ctx) {
|
||||||
|
const roots = [...new Set(
|
||||||
|
[ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((root) => path.resolve(root)),
|
||||||
|
)];
|
||||||
|
for (const root of roots) {
|
||||||
|
const found = readBuildPathAt(root);
|
||||||
|
if (!found) continue;
|
||||||
|
// "Never written back" is scoped by the fact that this directive exists at
|
||||||
|
// all: it is emitted only where a value is already recorded, which is the
|
||||||
|
// case where a flip really is session-only. Saying so inline because the
|
||||||
|
// bare absolute reads as a rule that overrides new-work's one-time offer,
|
||||||
|
// which is exactly how the same wording misfired in serve-question.
|
||||||
|
parts.push(`BUILD_PATH_DEFAULT: ${found.value} (from ${found.source}). Author direction and surface rounds with this as buildPath.value and toggle: true; a flip on the page binds that session only and is never written back, because a default is already recorded here. New-work's one-time offer to record a flipped value applies only where no default exists, which is why you are not seeing this line on those projects.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Image generation availability: harness-native tools always win, but when the
|
// Image generation availability: harness-native tools always win, but when the
|
||||||
// environment carries an OpenAI key the API fallback works everywhere. The
|
// environment carries an OpenAI key the API fallback works everywhere. The
|
||||||
// flag only reports capability, positively: absence stays silent, because a
|
// flag only reports capability, positively: absence stays silent, because a
|
||||||
|
|||||||
@@ -683,6 +683,10 @@ if (IS_BROWSER) {
|
|||||||
|
|
||||||
const reasons = collectVisualContrastReasons(el, style);
|
const reasons = collectVisualContrastReasons(el, style);
|
||||||
if (reasons.length === 0) continue;
|
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 textColor = parseRgb(style.color);
|
||||||
const fontSize = parseFloat(style.fontSize) || 16;
|
const fontSize = parseFloat(style.fontSize) || 16;
|
||||||
@@ -1175,6 +1179,7 @@ if (IS_BROWSER) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function analyzeVisualContrast(options = {}) {
|
async function analyzeVisualContrast(options = {}) {
|
||||||
|
// imageOnly is enforced inside the collector, before the candidate cap.
|
||||||
const candidates = collectVisualContrastCandidates(options);
|
const candidates = collectVisualContrastCandidates(options);
|
||||||
const results = [];
|
const results = [];
|
||||||
const shouldScrollOffscreen = options.scrollOffscreen === true;
|
const shouldScrollOffscreen = options.scrollOffscreen === true;
|
||||||
@@ -1260,9 +1265,16 @@ if (IS_BROWSER) {
|
|||||||
|
|
||||||
function addBrowserFindings(groupMap, el, findings) {
|
function addBrowserFindings(groupMap, el, findings) {
|
||||||
if (!findings || findings.length === 0) return;
|
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);
|
const existing = groupMap.get(el);
|
||||||
if (existing) existing.push(...findings);
|
if (existing) existing.push(...kept);
|
||||||
else groupMap.set(el, [...findings]);
|
else groupMap.set(el, [...kept]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function browserFindingsFromMap(groupMap) {
|
function browserFindingsFromMap(groupMap) {
|
||||||
@@ -1620,9 +1632,27 @@ if (IS_BROWSER) {
|
|||||||
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
|
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
|
||||||
node.remove();
|
node.remove();
|
||||||
}
|
}
|
||||||
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
|
// Regex findings that name a live selector resolve against the real DOM:
|
||||||
if (htmlPatternFindings.length > 0) {
|
// pseudo-element/class segments are stripped (the host element is the
|
||||||
const mapped = htmlPatternFindings.map(f => {
|
// 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 };
|
const item = { type: f.id, detail: f.snippet };
|
||||||
if (f.severity) {
|
if (f.severity) {
|
||||||
item.severity = 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 = {}) {
|
function shouldRunVisualContrast(options = {}) {
|
||||||
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
|
return visualContrastMode(options) !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function visualContrastOptions(options = {}) {
|
function visualContrastOptions(options = {}) {
|
||||||
@@ -1830,6 +1879,7 @@ if (IS_BROWSER) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const resolvedOptions = visualContrastOptions(options);
|
const resolvedOptions = visualContrastOptions(options);
|
||||||
|
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
|
||||||
const analyses = await analyzeVisualContrast(resolvedOptions);
|
const analyses = await analyzeVisualContrast(resolvedOptions);
|
||||||
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
|
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
|
||||||
lastVisualContrastAnalyses = analyses;
|
lastVisualContrastAnalyses = analyses;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
|||||||
// boundaries; `.impeccable` is our own project marker.
|
// boundaries; `.impeccable` is our own project marker.
|
||||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||||
|
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||||
|
// difference between a documented shadow and drift), so shadow matching cannot
|
||||||
|
// reuse the r/g/b-only channel tolerance.
|
||||||
|
const SHADOW_ALPHA_TOLERANCE = 0.02;
|
||||||
const RADIUS_TOLERANCE_PX = 0.5;
|
const RADIUS_TOLERANCE_PX = 0.5;
|
||||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||||
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
||||||
@@ -474,6 +478,25 @@ function addSidecarRadii(out, sidecar) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidecar `extensions.shadows` entries ({ name, value, purpose }) carry the
|
||||||
|
// documented shadow vocabulary that Stitch's frontmatter schema can't hold.
|
||||||
|
// Their colors go into a separate allowlist — NOT allowedColorKeys — because a
|
||||||
|
// shadow black is only documented *as a shadow*: feeding it into the general
|
||||||
|
// color allowlist would legalize #000 as a page ground (alpha is dropped from
|
||||||
|
// colorKey), which is the hole issue #547 warns against.
|
||||||
|
function addSidecarShadows(out, sidecar) {
|
||||||
|
const shadows = sidecar?.extensions?.shadows;
|
||||||
|
if (!Array.isArray(shadows)) return;
|
||||||
|
|
||||||
|
for (const entry of shadows) {
|
||||||
|
if (typeof entry?.value !== 'string') continue;
|
||||||
|
for (const match of entry.value.matchAll(CSS_COLOR_RE)) {
|
||||||
|
const parsed = parseDesignColor(match[0]);
|
||||||
|
if (parsed) out.allowedShadowColors.push({ color: parsed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDesignSystem(input = {}) {
|
function normalizeDesignSystem(input = {}) {
|
||||||
const frontmatter = input.frontmatter || {};
|
const frontmatter = input.frontmatter || {};
|
||||||
const sidecar = input.sidecar || null;
|
const sidecar = input.sidecar || null;
|
||||||
@@ -486,6 +509,7 @@ function normalizeDesignSystem(input = {}) {
|
|||||||
allowedColorKeys: new Map(),
|
allowedColorKeys: new Map(),
|
||||||
allowedRadii: [],
|
allowedRadii: [],
|
||||||
allowedFontSizes: [],
|
allowedFontSizes: [],
|
||||||
|
allowedShadowColors: [],
|
||||||
hasPillRadius: false,
|
hasPillRadius: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -495,6 +519,7 @@ function normalizeDesignSystem(input = {}) {
|
|||||||
addSidecarColors(out, sidecar);
|
addSidecarColors(out, sidecar);
|
||||||
addRoundedScale(out, frontmatter.rounded);
|
addRoundedScale(out, frontmatter.rounded);
|
||||||
addSidecarRadii(out, sidecar);
|
addSidecarRadii(out, sidecar);
|
||||||
|
addSidecarShadows(out, sidecar);
|
||||||
|
|
||||||
out.hasFonts = out.allowedFonts.size > 0;
|
out.hasFonts = out.allowedFonts.size > 0;
|
||||||
out.hasColors = out.allowedColorKeys.size > 0;
|
out.hasColors = out.allowedColorKeys.size > 0;
|
||||||
@@ -614,6 +639,20 @@ function isAllowedColorRaw(raw, designSystem) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A color is a documented shadow color only when both the r/g/b channels AND
|
||||||
|
// the alpha match a sidecar shadow token's color. Alpha has to be compared
|
||||||
|
// here because colorKey()/colorsClose() drop it, and a match on r/g/b alone
|
||||||
|
// would let every black at every alpha through.
|
||||||
|
function isAllowedShadowColorRaw(raw, designSystem) {
|
||||||
|
if (!designSystem?.allowedShadowColors?.length) return false;
|
||||||
|
const parsed = parseDesignColor(String(raw || '').trim().toLowerCase());
|
||||||
|
if (!parsed) return false;
|
||||||
|
return designSystem.allowedShadowColors.some(entry =>
|
||||||
|
colorsClose(parsed, entry.color) &&
|
||||||
|
Math.abs((parsed.a ?? 1) - (entry.color.a ?? 1)) <= SHADOW_ALPHA_TOLERANCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isAllowedRadiusRaw(raw, designSystem) {
|
function isAllowedRadiusRaw(raw, designSystem) {
|
||||||
if (!designSystem?.hasRadii) return true;
|
if (!designSystem?.hasRadii) return true;
|
||||||
const text = String(raw || '').trim().toLowerCase();
|
const text = String(raw || '').trim().toLowerCase();
|
||||||
@@ -691,6 +730,40 @@ function isProbablyColorLiteral(line, match) {
|
|||||||
return styleContext || cssFunctionContext || jsColorKeyContext;
|
return styleContext || cssFunctionContext || jsColorKeyContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One complete `${...}` template interpolation. Its content may carry paired
|
||||||
|
// quoted strings (function arguments, ternary branches) and one level of
|
||||||
|
// braces (an object-literal argument, itself allowing paired quotes). Deeper
|
||||||
|
// nesting would need a parser, so the regex deliberately fails safe there:
|
||||||
|
// the context check misses and the finding fires — a false positive a waiver
|
||||||
|
// can silence, never a leak.
|
||||||
|
const QUOTED_STRING_SRC = `"[^"]*"|'[^']*'`;
|
||||||
|
const INTERPOLATION_SRC =
|
||||||
|
`\\$\\{(?:${QUOTED_STRING_SRC}|\\{(?:${QUOTED_STRING_SRC}|[^{}"'\`])*\\}|[^{}"'\`])*\\}`;
|
||||||
|
// The two shadow-context tails. Unlike jsColorKeyContext, the JS tail admits
|
||||||
|
// commas: a multi-layer shadow string is comma-separated, and a later
|
||||||
|
// property on the same line is still blocked because it sits past the
|
||||||
|
// string's closing quote. Both tails admit complete interpolations; a bare
|
||||||
|
// `}`, quote, or `;` still ends the context.
|
||||||
|
const SHADOW_CSS_CONTEXT_RE = new RegExp(
|
||||||
|
`(?:^|[{\\s;"'\`(,])(?:box-shadow|text-shadow)\\s*:\\s*(?:${INTERPOLATION_SRC}|[^;{}"'\`])*$`, 'i',
|
||||||
|
);
|
||||||
|
const SHADOW_JS_CONTEXT_RE = new RegExp(
|
||||||
|
`(?:^|[,{]\\s*)(?:boxShadow|textShadow)\\s*[:=]\\s*["'\`]?(?:${INTERPOLATION_SRC}|[^"'\`}])*$`, 'i',
|
||||||
|
);
|
||||||
|
|
||||||
|
// True when the color literal sits inside a box-shadow / text-shadow value —
|
||||||
|
// the only contexts where a documented shadow color is legal. Anchored to the
|
||||||
|
// end of `before` (no ; } { or quote in between) so a shadow property earlier
|
||||||
|
// on the line can't leak the allowance into a later declaration. Kept separate
|
||||||
|
// from isProbablyColorLiteral(), which stays a boolean for its existing call
|
||||||
|
// sites and deliberately discards which property matched.
|
||||||
|
function isShadowPropertyContext(line, match) {
|
||||||
|
const index = match.index ?? -1;
|
||||||
|
if (index < 0) return false;
|
||||||
|
const before = line.slice(0, index);
|
||||||
|
return SHADOW_CSS_CONTEXT_RE.test(before) || SHADOW_JS_CONTEXT_RE.test(before);
|
||||||
|
}
|
||||||
|
|
||||||
function isInsideCssAttributeSelector(line, index) {
|
function isInsideCssAttributeSelector(line, index) {
|
||||||
if (index < 0) return false;
|
if (index < 0) return false;
|
||||||
const before = line.slice(0, index);
|
const before = line.slice(0, index);
|
||||||
@@ -824,6 +897,7 @@ function checkSourceDesignSystem(content, filePath, options = {}) {
|
|||||||
if (!isProbablyColorLiteral(line, match)) continue;
|
if (!isProbablyColorLiteral(line, match)) continue;
|
||||||
const raw = cssColorLabel(match[0]);
|
const raw = cssColorLabel(match[0]);
|
||||||
if (isAllowedColorRaw(raw, designSystem)) continue;
|
if (isAllowedColorRaw(raw, designSystem)) continue;
|
||||||
|
if (isShadowPropertyContext(line, match) && isAllowedShadowColorRaw(raw, designSystem)) continue;
|
||||||
findings.push(makeDesignFinding(
|
findings.push(makeDesignFinding(
|
||||||
'design-system-color',
|
'design-system-color',
|
||||||
filePath,
|
filePath,
|
||||||
@@ -1038,6 +1112,7 @@ export {
|
|||||||
loadDesignSystemForCwd,
|
loadDesignSystemForCwd,
|
||||||
isAllowedFont,
|
isAllowedFont,
|
||||||
isAllowedColorRaw,
|
isAllowedColorRaw,
|
||||||
|
isAllowedShadowColorRaw,
|
||||||
isAllowedRadiusRaw,
|
isAllowedRadiusRaw,
|
||||||
isAllowedFontSizeRaw,
|
isAllowedFontSizeRaw,
|
||||||
checkSourceDesignSystem,
|
checkSourceDesignSystem,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -425,25 +425,28 @@ const REGEX_MATCHERS = [
|
|||||||
},
|
},
|
||||||
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
|
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
|
||||||
// --- Layout property transition ---
|
// --- Layout property transition ---
|
||||||
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
|
// JSX inline style objects use comma-delimited quoted values, not semicolons (issue #548).
|
||||||
|
{ id: 'layout-transition', regex: /transition\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi,
|
||||||
test: (m) => {
|
test: (m) => {
|
||||||
const val = m[1].toLowerCase();
|
const val = (m[2] ?? m[3] ?? '').toLowerCase();
|
||||||
if (/\ball\b/.test(val)) return false;
|
if (/\ball\b/.test(val)) return false;
|
||||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||||
},
|
},
|
||||||
fmt: (m) => {
|
fmt: (m) => {
|
||||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
const raw = m[2] ?? m[3] ?? '';
|
||||||
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
|
const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||||
|
return `transition: ${found ? found.join(', ') : raw.trim()}`;
|
||||||
} },
|
} },
|
||||||
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
|
{ id: 'layout-transition', regex: /transition-property\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi,
|
||||||
test: (m) => {
|
test: (m) => {
|
||||||
const val = m[1].toLowerCase();
|
const val = (m[2] ?? m[3] ?? '').toLowerCase();
|
||||||
if (/\ball\b/.test(val)) return false;
|
if (/\ball\b/.test(val)) return false;
|
||||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||||
},
|
},
|
||||||
fmt: (m) => {
|
fmt: (m) => {
|
||||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
const raw = m[2] ?? m[3] ?? '';
|
||||||
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
|
const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||||
|
return `transition-property: ${found ? found.join(', ') : raw.trim()}`;
|
||||||
} },
|
} },
|
||||||
// --- Broken image: src="" or src="#" or src=" " ---
|
// --- Broken image: src="" or src="#" or src=" " ---
|
||||||
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
|
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ const STATIC_INHERITED_PROPS = new Set([
|
|||||||
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
|
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
|
||||||
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
|
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
|
||||||
'webkitHyphens',
|
'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 = {
|
const STATIC_DEFAULT_STYLE = {
|
||||||
@@ -278,6 +282,7 @@ const STATIC_DEFAULT_STYLE = {
|
|||||||
marginLeft: '0px',
|
marginLeft: '0px',
|
||||||
position: 'static',
|
position: 'static',
|
||||||
visibility: 'visible',
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
top: 'auto',
|
top: 'auto',
|
||||||
right: 'auto',
|
right: 'auto',
|
||||||
bottom: 'auto',
|
bottom: 'auto',
|
||||||
@@ -334,6 +339,7 @@ const STATIC_PROP_MAP = {
|
|||||||
'margin-left': 'marginLeft',
|
'margin-left': 'marginLeft',
|
||||||
'position': 'position',
|
'position': 'position',
|
||||||
'visibility': 'visibility',
|
'visibility': 'visibility',
|
||||||
|
'opacity': 'opacity',
|
||||||
'top': 'top',
|
'top': 'top',
|
||||||
'right': 'right',
|
'right': 'right',
|
||||||
'bottom': 'bottom',
|
'bottom': 'bottom',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
checkCreamPalette,
|
checkCreamPalette,
|
||||||
checkHtmlPatterns,
|
checkHtmlPatterns,
|
||||||
checkKickerAboveHeadingFromDoc,
|
checkKickerAboveHeadingFromDoc,
|
||||||
|
scopedIgnoreActive,
|
||||||
checkNumberedSectionLabelsFromDoc,
|
checkNumberedSectionLabelsFromDoc,
|
||||||
checkPageLayout,
|
checkPageLayout,
|
||||||
checkPageQualityFromDoc,
|
checkPageQualityFromDoc,
|
||||||
@@ -138,10 +139,21 @@ async function detectHtml(filePath, options = {}) {
|
|||||||
domutils,
|
domutils,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
return detectText(html, filePath, options);
|
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 resolvedPath = path.resolve(filePath);
|
||||||
const fileDir = path.dirname(resolvedPath);
|
const fileDir = path.dirname(resolvedPath);
|
||||||
const root = profileStep(profile, {
|
const root = profileStep(profile, {
|
||||||
@@ -171,6 +183,9 @@ async function detectHtml(filePath, options = {}) {
|
|||||||
const tag = el.tagName.toLowerCase();
|
const tag = el.tagName.toLowerCase();
|
||||||
const style = window.getComputedStyle(el);
|
const style = window.getComputedStyle(el);
|
||||||
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
|
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));
|
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 =>
|
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html, patternCorpora).filter(item =>
|
||||||
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
|
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);
|
const item = finding(f.id, filePath, f.snippet);
|
||||||
// Position-aware severity promotion: checks may attach a per-finding
|
// Position-aware severity promotion: checks may attach a per-finding
|
||||||
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
||||||
|
|||||||
@@ -11,14 +11,21 @@ import {
|
|||||||
isBrandFontOnOwnDomain,
|
isBrandFontOnOwnDomain,
|
||||||
} from '../shared/constants.mjs';
|
} from '../shared/constants.mjs';
|
||||||
import {
|
import {
|
||||||
|
CSS_NAMED_COLORS,
|
||||||
colorToHex,
|
colorToHex,
|
||||||
|
compositeColorOver,
|
||||||
contrastRatio,
|
contrastRatio,
|
||||||
getHue,
|
getHue,
|
||||||
hasChroma,
|
hasChroma,
|
||||||
isNeutralColor,
|
isNeutralColor,
|
||||||
|
isNoPaintColorValue,
|
||||||
|
oklchToRgb,
|
||||||
|
parseAnyColor,
|
||||||
|
parseColorMix,
|
||||||
parseGradientColors,
|
parseGradientColors,
|
||||||
parseRgb,
|
parseRgb,
|
||||||
relativeLuminance,
|
relativeLuminance,
|
||||||
|
splitTopLevelCommas,
|
||||||
} from '../shared/color.mjs';
|
} from '../shared/color.mjs';
|
||||||
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
|
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
|
||||||
|
|
||||||
@@ -70,6 +77,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
|
|||||||
return findings;
|
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
|
// Returns true if the given text is composed entirely of emoji characters
|
||||||
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
|
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
|
||||||
// regardless of CSS `color`, so contrast checks against the element's text
|
// regardless of CSS `color`, so contrast checks against the element's text
|
||||||
@@ -637,6 +672,26 @@ function cssTextHasDarkRootBg(content, customProps) {
|
|||||||
return false;
|
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) {
|
function scanCssTextForGlow(content) {
|
||||||
const customProps = collectCssCustomProps(content);
|
const customProps = collectCssCustomProps(content);
|
||||||
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
|
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
|
||||||
@@ -948,6 +1003,7 @@ function scanCssTextForPseudoStripe(rawContent) {
|
|||||||
id: 'side-tab',
|
id: 'side-tab',
|
||||||
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
||||||
index: selectorStart,
|
index: selectorStart,
|
||||||
|
selector,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return findings;
|
return findings;
|
||||||
@@ -1010,6 +1066,7 @@ function scanCssTextForInsetStripe(content) {
|
|||||||
findings.push({
|
findings.push({
|
||||||
id: 'side-tab',
|
id: 'side-tab',
|
||||||
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
|
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
|
||||||
|
selector,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1067,7 +1124,7 @@ function collectMarqueeKeyframes(content) {
|
|||||||
function scanCssTextForMarquee(content, markup = content) {
|
function scanCssTextForMarquee(content, markup = content) {
|
||||||
const findings = [];
|
const findings = [];
|
||||||
if (/<marquee\b/i.test(markup)) {
|
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);
|
const marqueeKeyframes = collectMarqueeKeyframes(content);
|
||||||
if (marqueeKeyframes.size === 0) return findings;
|
if (marqueeKeyframes.size === 0) return findings;
|
||||||
@@ -1082,7 +1139,7 @@ function scanCssTextForMarquee(content, markup = content) {
|
|||||||
const key = `${selector} ${name}`;
|
const key = `${selector} ${name}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
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;
|
return findings;
|
||||||
@@ -1453,8 +1510,10 @@ function checkHtmlPatterns(html, corpora) {
|
|||||||
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
||||||
if (purpleHexRe.test(styleText)) {
|
if (purpleHexRe.test(styleText)) {
|
||||||
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
|
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
|
||||||
if (purpleTextRe.test(styleText)) {
|
purpleTextRe.lastIndex = 0;
|
||||||
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
|
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 start = Math.max(0, gm.index - 200);
|
||||||
const context = styleText.substring(start, gm.index + gm[0].length + 200);
|
const context = styleText.substring(start, gm.index + gm[0].length + 200);
|
||||||
if (/gradient/i.test(context)) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1531,7 +1590,7 @@ function checkHtmlPatterns(html, corpora) {
|
|||||||
const animationToken = bounceMatch[1]
|
const animationToken = bounceMatch[1]
|
||||||
.split(/[,\s]+/)
|
.split(/[,\s]+/)
|
||||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
.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
|
// Overshoot cubic-bezier
|
||||||
@@ -1540,7 +1599,7 @@ function checkHtmlPatterns(html, corpora) {
|
|||||||
while ((bm = bezierRe.exec(styleText)) !== null) {
|
while ((bm = bezierRe.exec(styleText)) !== null) {
|
||||||
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
|
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
|
||||||
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1573,18 +1632,21 @@ function checkHtmlPatterns(html, corpora) {
|
|||||||
|
|
||||||
const glowHits = scanCssTextForGlow(styleText);
|
const glowHits = scanCssTextForGlow(styleText);
|
||||||
if (glowHits.length > 0) {
|
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)
|
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
|
||||||
const haloHits = scanCssTextForRadialHalo(styleText);
|
const haloHits = scanCssTextForRadialHalo(styleText);
|
||||||
if (haloHits.length > 0) {
|
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 ---
|
// --- 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 ---
|
// --- Generated-UI tells: two-axis grid-line background ---
|
||||||
@@ -1602,7 +1664,7 @@ function checkHtmlPatterns(html, corpora) {
|
|||||||
// whole gradient layers.
|
// whole gradient layers.
|
||||||
const gridHits = scanCssTextForGridBackground(styleText);
|
const gridHits = scanCssTextForGridBackground(styleText);
|
||||||
if (gridHits.length > 0) {
|
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 ---
|
// --- 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
|
// hover:rotate / hover:translate utility on an <img>. Each distinct
|
||||||
// mechanism is its own finding.
|
// mechanism is its own finding.
|
||||||
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
|
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;
|
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
|
||||||
let im;
|
let im;
|
||||||
@@ -1670,7 +1735,46 @@ function readOwnBackgroundColor(el, computedStyle) {
|
|||||||
return bg;
|
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;
|
let current = el;
|
||||||
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
|
// 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
|
// base. A browser composites these over the base; the old behavior
|
||||||
@@ -1698,67 +1802,114 @@ function resolveBackground(el, win, customPropMap) {
|
|||||||
// body backgrounds.
|
// body backgrounds.
|
||||||
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
|
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
|
||||||
// (e.g. any color-mix() result), which plain parseRgb misses.
|
// (e.g. any color-mix() result), which plain parseRgb misses.
|
||||||
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
let bg = readCascadeBackgroundColor(current, style, customPropMap);
|
||||||
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
|
|
||||||
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
|
// `background-color: currentcolor` paints with the element's own text
|
||||||
// through customPropMap so Tailwind v4 color tokens become RGB.
|
// color — real paint whose value we know. Real browsers resolve the
|
||||||
if (customPropMap) {
|
// keyword before getComputedStyle output; jsdom hands it through
|
||||||
bg = parseColorResolved(style.backgroundColor, customPropMap);
|
// verbatim, and without this substitution the layer would read as
|
||||||
}
|
// unparseable and force a needless abstention.
|
||||||
if (!bg || bg.a < 0.1) {
|
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
|
||||||
// Inline-style fallback. jsdom doesn't decompose background
|
// The static cascade resolves var() text tokens before checks run, so
|
||||||
// shorthand, so colors set via inline style are otherwise invisible.
|
// style.color is normally already an rgb string here; parseColorResolved
|
||||||
const rawStyle = current.getAttribute?.('style') || '';
|
// is defense in depth for any future caller that passes a live
|
||||||
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
// customPropMap (it matches the text-color path in checkElementColors
|
||||||
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
|
// and reduces to parseAnyColor when the map is null or absent).
|
||||||
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
|
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
|
||||||
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bg && bg.a > 0.1) {
|
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);
|
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
|
// No solid bg-color at this level, but this level paints an image. CSS
|
||||||
// with no underlying solid color we can read:
|
// stacks background-image layers first-on-top, so which layer leads
|
||||||
// • on body/html: assume white. Body-level gradients are almost
|
// decides what the visitor sees:
|
||||||
// always decorative texture (paper grain, noise) on top of a
|
// • gradient on top — the gradient is the surface. Hand the caller a
|
||||||
// solid bg-color the page set via `background: var(--paper)`
|
// null color so it falls back to the gradient's own stops (body
|
||||||
// shorthand — which jsdom can't decompose into bg-color. The
|
// grounds, gradient buttons, hero sections).
|
||||||
// downstream gradient-stops fallback path produces catastrophic
|
// • url() on top — the surface is an image whose pixels this engine
|
||||||
// false positives in this case (gradient noise stops have
|
// cannot read, and it may fully cover every layer and ancestor
|
||||||
// accidental browns/blacks that look like card backgrounds).
|
// beneath it. Same contract as an unparseable color: abstain, so
|
||||||
// • on other elements: bail to null and let the caller fall back
|
// the gradient-stop fallback never measures a gradient the image
|
||||||
// to gradient stops (gradient buttons / hero sections are real
|
// hides (the shipped miss: `url(photo), linear-gradient(...)`
|
||||||
// bgs worth checking against).
|
// reported low-contrast against the invisible gradient's stops).
|
||||||
if (hasGradientOrUrl) {
|
if (hasGradientOrUrl) {
|
||||||
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
|
const layers = splitTopLevelCommas(bgImage);
|
||||||
return flatten({ r: 255, g: 255, b: 255, a: 1 });
|
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;
|
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.
|
// Walk parents looking for a gradient background and return its color stops.
|
||||||
// Used as a fallback when resolveBackground() returns null because the
|
// Used as a fallback when resolveBackground() returns null because the
|
||||||
// effective background is a gradient (no single solid color to compare against).
|
// 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) {
|
function resolveGradientStops(el, win, customPropMap) {
|
||||||
let current = el;
|
let current = el;
|
||||||
|
const overlays = [];
|
||||||
while (current && current.nodeType === 1) {
|
while (current && current.nodeType === 1) {
|
||||||
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
||||||
const bgImage = style.backgroundImage || '';
|
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;
|
let stops = null;
|
||||||
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
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);
|
const parsed = parseGradientColors(bgImage);
|
||||||
if (parsed.length > 0) stops = parsed;
|
if (parsed.length > 0) stops = parsed;
|
||||||
}
|
}
|
||||||
if (!stops && !DETECTOR_IS_BROWSER) {
|
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 rawStyle = current.getAttribute?.('style') || '';
|
||||||
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
||||||
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
||||||
@@ -1766,7 +1917,23 @@ function resolveGradientStops(el, win, customPropMap) {
|
|||||||
if (parsed.length > 0) stops = parsed;
|
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;
|
current = current.parentElement;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -1986,15 +2153,25 @@ function checkElementColorsDOM(el) {
|
|||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
if (rect.width < 10 || rect.height < 10) return [];
|
if (rect.width < 10 || rect.height < 10) return [];
|
||||||
const style = getComputedStyle(el);
|
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 directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||||
const hasDirectText = directText.trim().length > 0;
|
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);
|
let ownBg = readOwnBackgroundColor(el, style);
|
||||||
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
|
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
|
||||||
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
|
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
|
||||||
if (pseudoSurface) {
|
if (pseudoSurface) {
|
||||||
ownBg = pseudoSurface;
|
ownBg = pseudoSurface;
|
||||||
effectiveBg = pseudoSurface;
|
effectiveBg = pseudoSurface;
|
||||||
|
surfaceUnresolved = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return checkColors({
|
return checkColors({
|
||||||
@@ -2006,8 +2183,8 @@ function checkElementColorsDOM(el) {
|
|||||||
// an oklch token near its own oklch background).
|
// an oklch token near its own oklch background).
|
||||||
textColor: parseRgb(style.color) || parseAnyColor(style.color),
|
textColor: parseRgb(style.color) || parseAnyColor(style.color),
|
||||||
bgColor: ownBg,
|
bgColor: ownBg,
|
||||||
effectiveBg,
|
effectiveBg: surfaceUnresolved ? null : effectiveBg,
|
||||||
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
|
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
|
||||||
fontSize: parseFloat(style.fontSize) || 16,
|
fontSize: parseFloat(style.fontSize) || 16,
|
||||||
fontWeight: parseInt(style.fontWeight) || 400,
|
fontWeight: parseInt(style.fontWeight) || 400,
|
||||||
hasDirectText,
|
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.
|
// Resolve var() refs in a color string (via customPropMap), then parse.
|
||||||
// Returns null on any failure. Used in jsdom-mode paths where
|
// Returns null on any failure. Used in jsdom-mode paths where
|
||||||
@@ -2796,9 +2696,20 @@ function checkElementGlowDOM(el) {
|
|||||||
if (!boxShadow && !textShadow) return [];
|
if (!boxShadow && !textShadow) return [];
|
||||||
// Use parent's background — glow radiates outward, so the surrounding context matters
|
// Use parent's background — glow radiates outward, so the surrounding context matters
|
||||||
// If resolveBackground returns null (gradient), try to infer from the gradient colors
|
// If resolveBackground returns null (gradient), try to infer from the gradient colors
|
||||||
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
|
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
|
||||||
if (!parentBg) {
|
// Unknown surface (an unreadable layer on the way up): skip only the
|
||||||
// Gradient background — sample its colors to determine if it's dark
|
// 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;
|
let cur = el.parentElement;
|
||||||
while (cur && cur.nodeType === 1) {
|
while (cur && cur.nodeType === 1) {
|
||||||
const bgImage = getComputedStyle(cur).backgroundImage || '';
|
const bgImage = getComputedStyle(cur).backgroundImage || '';
|
||||||
@@ -2846,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
|
|||||||
const hue = getHue(textColor);
|
const hue = getHue(textColor);
|
||||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||||
if (isAIPalette) {
|
if (isAIPalette) {
|
||||||
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
|
const parentBgInfo = el.parentElement
|
||||||
// Also check gradient parents
|
? resolveBackgroundInfo(el.parentElement)
|
||||||
let effectiveBg = parentBg;
|
: { color: null, unresolved: false };
|
||||||
if (!effectiveBg) {
|
// 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;
|
let cur = el.parentElement;
|
||||||
while (cur && cur.nodeType === 1) {
|
while (cur && cur.nodeType === 1) {
|
||||||
const gi = getComputedStyle(cur).backgroundImage || '';
|
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) {
|
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 directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||||
const hasDirectText = directText.trim().length > 0;
|
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
|
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
|
||||||
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
|
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
|
||||||
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
|
// 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
|
// element itself has no usable own background, that pseudo is the real
|
||||||
// surface for contrast purposes.
|
// surface for contrast purposes.
|
||||||
let finalEffectiveBg = effectiveBg;
|
let finalEffectiveBg = effectiveBg;
|
||||||
|
let surfaceUnresolved = bgInfo.unresolved;
|
||||||
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
|
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
|
||||||
const pseudoSurface = window.getPseudoSurface(el);
|
const pseudoSurface = window.getPseudoSurface(el);
|
||||||
if (pseudoSurface) {
|
if (pseudoSurface) {
|
||||||
ownBg = pseudoSurface;
|
ownBg = pseudoSurface;
|
||||||
finalEffectiveBg = pseudoSurface;
|
finalEffectiveBg = pseudoSurface;
|
||||||
|
surfaceUnresolved = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3705,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
|||||||
tag,
|
tag,
|
||||||
textColor,
|
textColor,
|
||||||
bgColor: ownBg,
|
bgColor: ownBg,
|
||||||
effectiveBg: finalEffectiveBg,
|
// Unknown surface: hand the checks nothing rather than a guess.
|
||||||
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
|
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
|
||||||
|
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
|
||||||
fontSize: parseFloat(style.fontSize) || 16,
|
fontSize: parseFloat(style.fontSize) || 16,
|
||||||
fontWeight: parseInt(style.fontWeight) || 400,
|
fontWeight: parseInt(style.fontWeight) || 400,
|
||||||
hasDirectText,
|
hasDirectText,
|
||||||
@@ -4802,6 +4728,11 @@ function isRenderedForBrowserRule(el) {
|
|||||||
function checkElementTextOverflowDOM(el) {
|
function checkElementTextOverflowDOM(el) {
|
||||||
const tag = el.tagName.toLowerCase();
|
const tag = el.tagName.toLowerCase();
|
||||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
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 [];
|
if (!isRenderedForBrowserRule(el)) return [];
|
||||||
// Only the element that actually owns overflowing text — not its ancestors,
|
// Only the element that actually owns overflowing text — not its ancestors,
|
||||||
// which inherit a wider scrollWidth from the spilling descendant.
|
// 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.
|
// path is pure geometry and runs anywhere on the page.
|
||||||
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
|
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() {
|
function checkTextOcclusionDOM() {
|
||||||
const findings = [];
|
const findings = [];
|
||||||
const seenVictims = new Set();
|
const seenVictims = new Set();
|
||||||
@@ -5213,6 +5160,11 @@ function checkTextOcclusionDOM() {
|
|||||||
}
|
}
|
||||||
return false;
|
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
|
// Collect renderable text owners in / near the first viewport for the
|
||||||
// elementFromPoint probe. SVG <text> counts too.
|
// elementFromPoint probe. SVG <text> counts too.
|
||||||
@@ -5225,6 +5177,7 @@ function checkTextOcclusionDOM() {
|
|||||||
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
|
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
|
||||||
if (text.length < 2) continue;
|
if (text.length < 2) continue;
|
||||||
if (!isPaintedForOcclusion(el)) continue;
|
if (!isPaintedForOcclusion(el)) continue;
|
||||||
|
if (effectiveOpacity(el) <= 0.02) continue;
|
||||||
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
|
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
|
||||||
if (rect.width < 6 || rect.height < 6) continue;
|
if (rect.width < 6 || rect.height < 6) continue;
|
||||||
// Viewport-bound probe: keep text whose box overlaps the live viewport.
|
// 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;
|
if (top === el || el.contains(top) || top.contains(el)) continue;
|
||||||
const topCs = getComputedStyle(top);
|
const topCs = getComputedStyle(top);
|
||||||
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
|
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
|
||||||
|
if (effectiveOpacity(top) <= 0.02) continue;
|
||||||
const topTag = top.tagName.toLowerCase();
|
const topTag = top.tagName.toLowerCase();
|
||||||
// Text sitting under a raw image/video is contrast territory (deduped
|
// Text sitting under a raw image/video is contrast territory (deduped
|
||||||
// against the pixel low-contrast rule); leave those alone here.
|
// against the pixel low-contrast rule); leave those alone here.
|
||||||
@@ -5468,6 +5422,7 @@ export {
|
|||||||
CSS_NAMED_COLORS,
|
CSS_NAMED_COLORS,
|
||||||
checkBorders,
|
checkBorders,
|
||||||
isEmojiOnlyText,
|
isEmojiOnlyText,
|
||||||
|
scopedIgnoreActive,
|
||||||
checkColors,
|
checkColors,
|
||||||
checkHoverContrast,
|
checkHoverContrast,
|
||||||
checkElementHoverContrast,
|
checkElementHoverContrast,
|
||||||
@@ -5497,6 +5452,7 @@ export {
|
|||||||
checkHtmlPatterns,
|
checkHtmlPatterns,
|
||||||
readOwnBackgroundColor,
|
readOwnBackgroundColor,
|
||||||
resolveBackground,
|
resolveBackground,
|
||||||
|
resolveBackgroundInfo,
|
||||||
resolveGradientStops,
|
resolveGradientStops,
|
||||||
parseRadiusToPx,
|
parseRadiusToPx,
|
||||||
resolveBorderRadiusPx,
|
resolveBorderRadiusPx,
|
||||||
|
|||||||
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
|
|||||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
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) {
|
function parseGradientColors(bgImage) {
|
||||||
if (!bgImage || !bgImage.includes('gradient')) return [];
|
if (!bgImage || !bgImage.includes('gradient')) return [];
|
||||||
const colors = [];
|
const colors = [];
|
||||||
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
|
// Stops arrive in whatever syntax the author wrote and the browser kept.
|
||||||
const c = parseRgb(m[0]);
|
// 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);
|
if (c) colors.push(c);
|
||||||
}
|
}
|
||||||
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
|
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('');
|
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 {
|
export {
|
||||||
isNeutralColor,
|
isNeutralColor,
|
||||||
parseRgb,
|
parseRgb,
|
||||||
relativeLuminance,
|
relativeLuminance,
|
||||||
contrastRatio,
|
contrastRatio,
|
||||||
parseGradientColors,
|
parseGradientColors,
|
||||||
|
extractColorFunctionTokens,
|
||||||
hasChroma,
|
hasChroma,
|
||||||
getHue,
|
getHue,
|
||||||
colorToHex,
|
colorToHex,
|
||||||
|
oklabToRgb,
|
||||||
|
oklchToRgb,
|
||||||
|
labToRgb,
|
||||||
|
lchToRgb,
|
||||||
|
colorFunctionToRgb,
|
||||||
|
hslToRgb,
|
||||||
|
hwbToRgb,
|
||||||
|
CSS_NAMED_COLORS,
|
||||||
|
splitTopLevelCommas,
|
||||||
|
parseColorMix,
|
||||||
|
parseAnyColor,
|
||||||
|
compositeColorOver,
|
||||||
|
isNoPaintColorValue,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
stampProductSchema,
|
stampProductSchema,
|
||||||
} from './lib/artifact-schema.mjs';
|
} from './lib/artifact-schema.mjs';
|
||||||
import {
|
import {
|
||||||
|
checkBuildPathUnset,
|
||||||
checkConfig,
|
checkConfig,
|
||||||
checkDesignSidecar,
|
checkDesignSidecar,
|
||||||
checkNativePlatformEvidence,
|
checkNativePlatformEvidence,
|
||||||
@@ -120,6 +121,7 @@ async function collect(cwd, targetOptions) {
|
|||||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||||
|
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||||
...checkHookInstallation({
|
...checkHookInstallation({
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
*
|
*
|
||||||
* node generate-image.mjs --prompt "..." --out mock.png [--size 1536x1024] [--quality medium]
|
* node generate-image.mjs --prompt "..." --out mock.png [--size 1536x1024] [--quality medium]
|
||||||
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
|
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
|
||||||
|
* node generate-image.mjs --prompt "..." --out mock.png --ref screenshot.png [--ref more.png]
|
||||||
|
*
|
||||||
|
* --ref anchors generation on input image(s) via the edits endpoint: pass a
|
||||||
|
* captured screenshot of a representative existing page when comping a new
|
||||||
|
* surface for an established world, so the identity comes from the real UI.
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import zlib from 'node:zlib';
|
import zlib from 'node:zlib';
|
||||||
@@ -212,12 +217,44 @@ if (!prompt || !out) {
|
|||||||
}
|
}
|
||||||
const size = arg('size', '1536x1024');
|
const size = arg('size', '1536x1024');
|
||||||
const quality = arg('quality', 'medium');
|
const quality = arg('quality', 'medium');
|
||||||
|
// Reference images (--ref, repeatable): route through the edits endpoint,
|
||||||
|
// which accepts input images. This is how a comp for an established world
|
||||||
|
// inherits the real UI's identity from a captured screenshot instead of a
|
||||||
|
// prose paraphrase of it; the prompt then describes the NEW surface and the
|
||||||
|
// reference carries palette, type, and component character.
|
||||||
|
const refs = (() => {
|
||||||
|
const found = [];
|
||||||
|
for (let i = 0; i < process.argv.length; i += 1) {
|
||||||
|
if (process.argv[i] === '--ref' && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) found.push(process.argv[i + 1]);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
})();
|
||||||
|
|
||||||
const response = await fetch('https://api.openai.com/v1/images/generations', {
|
let response;
|
||||||
method: 'POST',
|
if (refs.length) {
|
||||||
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
const form = new FormData();
|
||||||
body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
|
form.append('model', 'gpt-image-2');
|
||||||
});
|
form.append('prompt', prompt);
|
||||||
|
form.append('size', size);
|
||||||
|
form.append('quality', quality);
|
||||||
|
form.append('n', '1');
|
||||||
|
for (const ref of refs) {
|
||||||
|
const bytes = fs.readFileSync(ref);
|
||||||
|
const type = ref.endsWith('.png') ? 'image/png' : ref.endsWith('.webp') ? 'image/webp' : 'image/jpeg';
|
||||||
|
form.append('image[]', new Blob([bytes], { type }), ref.split('/').pop());
|
||||||
|
}
|
||||||
|
response = await fetch('https://api.openai.com/v1/images/edits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${key}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch('https://api.openai.com/v1/images/generations', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error(`generate-image: API error ${response.status}: ${(await response.text()).slice(0, 300)}`);
|
console.error(`generate-image: API error ${response.status}: ${(await response.text()).slice(0, 300)}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -235,6 +272,6 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
|
|||||||
try {
|
try {
|
||||||
const { spawnSync } = await import('node:child_process');
|
const { spawnSync } = await import('node:child_process');
|
||||||
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
||||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2' }, null, 2));
|
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
|
||||||
} catch { /* embedding is best-effort */ }
|
} catch { /* embedding is best-effort */ }
|
||||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ALLOWED_EXTS,
|
ALLOWED_EXTS,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
EDIT_COUNT_THRESHOLD,
|
EDIT_COUNT_THRESHOLD,
|
||||||
GENERATED_PATH,
|
GENERATED_PATH,
|
||||||
SENSITIVE_PATH,
|
SENSITIVE_PATH,
|
||||||
appendDesignSystemNote,
|
appendDesignSystemNoteOnce,
|
||||||
|
commitFooterShown,
|
||||||
|
designNoteReserve,
|
||||||
designSystemOptions,
|
designSystemOptions,
|
||||||
|
footerModeForSession,
|
||||||
filterFindings,
|
filterFindings,
|
||||||
isNativePlatform,
|
isNativePlatform,
|
||||||
isScanTargetInsideProject,
|
isScanTargetInsideProject,
|
||||||
@@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
// Cursor caps deny messages around 4000 chars. The cap feeds through the
|
||||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
// renderer's clamp, which preserves the policy footer, rather than tail-
|
||||||
const blocked = rendered.replace(
|
// 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] 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) {
|
function findingSignature(findings) {
|
||||||
@@ -468,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 sessionId = event.session_id || event.conversation_id || 'unknown';
|
||||||
const cache = readCache(cwd);
|
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);
|
const denial = bumpCursorDenial(cache, sessionId, filePath, filtered);
|
||||||
persistCache(cwd, cache);
|
persistCache(cwd, cache);
|
||||||
if (denial.count > EDIT_COUNT_THRESHOLD) {
|
if (denial.count > EDIT_COUNT_THRESHOLD) {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
||||||
* renderTemplate(findings, filePath, config, opts)
|
* renderTemplate(findings, filePath, config, opts)
|
||||||
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, 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?)
|
* shouldEmitAckForFile(filePath, config?)
|
||||||
* writeAuditLog(env, entry)
|
* writeAuditLog(env, entry)
|
||||||
* loadDetector() -> Promise<{ detectText, detectHtml }>
|
* loadDetector() -> Promise<{ detectText, detectHtml }>
|
||||||
@@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
|||||||
if (!Array.isArray(findings) || findings.length === 0) return '';
|
if (!Array.isArray(findings) || findings.length === 0) return '';
|
||||||
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
||||||
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
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 cwd = opts.cwd || process.cwd();
|
||||||
const display = relativize(filePath, cwd);
|
const display = relativize(filePath, cwd);
|
||||||
@@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
|||||||
const remaining = total - shown.length;
|
const remaining = total - shown.length;
|
||||||
|
|
||||||
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
|
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
|
const more = remaining > 0
|
||||||
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
|
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
|
||||||
: null;
|
: null;
|
||||||
const footer = directiveFooter(display);
|
const footer = directiveFooter({ mode: opts.footer });
|
||||||
|
|
||||||
const blocks = [header, ...lines];
|
const blocks = [header, ...lines];
|
||||||
if (more) blocks.push(more);
|
if (more) blocks.push(more);
|
||||||
@@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
|||||||
|
|
||||||
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
||||||
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
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 cwd = opts.cwd || process.cwd();
|
||||||
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
|
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 header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
|
||||||
const lines = [];
|
const lines = [];
|
||||||
let shownCount = 0;
|
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) {
|
for (const group of realGroups) {
|
||||||
const display = relativize(group.filePath, cwd);
|
const display = relativize(group.filePath, cwd);
|
||||||
@@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
|||||||
const remainingCap = Math.max(0, cap - shownCount);
|
const remainingCap = Math.max(0, cap - shownCount);
|
||||||
const shown = group.findings.slice(0, remainingCap);
|
const shown = group.findings.slice(0, remainingCap);
|
||||||
for (const finding of shown) {
|
for (const finding of shown) {
|
||||||
lines.push(formatFindingLine(finding));
|
lines.push(formatDedupedFindingLine(finding, seenRules));
|
||||||
}
|
}
|
||||||
shownCount += shown.length;
|
shownCount += shown.length;
|
||||||
const hidden = group.findings.length - 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');
|
let text = [header, ...lines, '', footer].join('\n');
|
||||||
if (text.length > maxChars) {
|
if (text.length > maxChars) {
|
||||||
text = clampGroupedToBudget(header, lines, footer, maxChars);
|
text = clampGroupedToBudget(header, lines, footer, maxChars);
|
||||||
@@ -1037,82 +1050,149 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
|||||||
return text;
|
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) {
|
function clampGroupedToBudget(header, lines, footer, maxChars) {
|
||||||
const assemble = (linesArr, omitted) => [
|
const assemble = (linesArr, omitted, footerText) => [
|
||||||
header,
|
header,
|
||||||
...linesArr,
|
...linesArr,
|
||||||
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
|
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
|
||||||
'',
|
'',
|
||||||
footer,
|
footerText,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
let working = lines.slice();
|
for (const footerText of footerFallbacks(footer)) {
|
||||||
let omitted = false;
|
let working = lines.slice();
|
||||||
let assembled = assemble(working, omitted);
|
let omitted = false;
|
||||||
while (assembled.length > maxChars && working.length > 1) {
|
let assembled = assemble(working, omitted, footerText);
|
||||||
working.pop();
|
while (assembled.length > maxChars && working.length > 1) {
|
||||||
omitted = true;
|
working.pop();
|
||||||
assembled = assemble(working, omitted);
|
omitted = true;
|
||||||
|
assembled = assemble(working, omitted, footerText);
|
||||||
|
}
|
||||||
|
if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled;
|
||||||
}
|
}
|
||||||
if (assembled.length > maxChars) {
|
return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText),
|
||||||
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
lines.find(isFindingLine) || lines[0], maxChars);
|
||||||
}
|
|
||||||
return assembled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampToBudget(header, lines, more, footer, maxChars) {
|
function clampToBudget(header, lines, more, footer, maxChars) {
|
||||||
const assemble = (linesArr, moreText) => {
|
const assemble = (linesArr, moreText, footerText) => {
|
||||||
const blocks = [header, ...linesArr];
|
const blocks = [header, ...linesArr];
|
||||||
if (moreText) blocks.push(moreText);
|
if (moreText) blocks.push(moreText);
|
||||||
blocks.push('');
|
blocks.push('');
|
||||||
blocks.push(footer);
|
blocks.push(footerText);
|
||||||
return blocks.join('\n');
|
return blocks.join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
let working = lines.slice();
|
let lastMore = more;
|
||||||
let moreText = more;
|
for (const footerText of footerFallbacks(footer)) {
|
||||||
let assembled = assemble(working, moreText);
|
let working = lines.slice();
|
||||||
while (assembled.length > maxChars && working.length > 1) {
|
let moreText = more;
|
||||||
working.pop();
|
let assembled = assemble(working, moreText, footerText);
|
||||||
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
|
while (assembled.length > maxChars && working.length > 1) {
|
||||||
assembled = assemble(working, moreText);
|
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) {
|
return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText),
|
||||||
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
lines.find(isFindingLine) || lines[0], maxChars);
|
||||||
}
|
|
||||||
return assembled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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();
|
const name = (f.name || '').trim();
|
||||||
// Description from the registry already ends in punctuation; join with a
|
// Description from the registry already ends in punctuation; join with a
|
||||||
// single space. `name` may have a trailing period already, keep it clean.
|
// single space. `name` may have a trailing period already, keep it clean.
|
||||||
const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
|
const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
|
||||||
const ignoreCommand = formatFindingIgnoreCommand(f);
|
const ignoreHint = formatFindingIgnoreHint(f);
|
||||||
const ignoreSegment = ignoreCommand
|
const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : '';
|
||||||
? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`
|
|
||||||
: '';
|
|
||||||
return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
|
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 '';
|
if (!finding || typeof finding !== 'object') return '';
|
||||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||||
if (!rule) return '';
|
if (!rule) return '';
|
||||||
const normalizedValue = extractFindingIgnoreValue(finding);
|
const normalizedValue = extractFindingIgnoreValue(finding);
|
||||||
if (!normalizedValue) return '';
|
if (!normalizedValue) return '';
|
||||||
const value = extractFindingIgnoreValueRaw(finding);
|
const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding));
|
||||||
const valueArg = quoteCommandArg(value);
|
return `ignore-value ${rule} ${valueArg}`;
|
||||||
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
|
|
||||||
return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function quoteCommandArg(value) {
|
function quoteCommandArg(value) {
|
||||||
const text = String(value || '').trim();
|
const text = String(value || '').trim();
|
||||||
if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
|
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) {
|
function relativize(filePath, cwd) {
|
||||||
@@ -1594,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) {
|
export function appendDesignSystemNote(text, scanOptions) {
|
||||||
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
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
|
// The directive footer is the part of the hook output that steers model
|
||||||
// behavior. Three intentional moves:
|
// behavior. Intentional moves, in order:
|
||||||
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
|
// 1. **Imperative, not advisory.** "Triage each finding..." beats
|
||||||
// revising..." which the model treats as a soft suggestion it can
|
// "Consider revising...", which the model treats as a soft suggestion.
|
||||||
// override when the user asked for any kind of throwaway / demo UI.
|
// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The
|
||||||
// 2. **Explicit judgment clause.** Without it, the model will try to
|
// suppress branch names the calibration examples (demo, fixture,
|
||||||
// "fix" intentional motion, bad fixtures, anti-pattern examples in
|
// documented bad design, user-confirmed choice) because the agent now
|
||||||
// docs, or test cases. Naming the judgment inline beats hoping the
|
// acts on its own confidence and needs the bar stated.
|
||||||
// model infers it from context.
|
// 3. **Executable ignore path.** The old footer named only the slash
|
||||||
// 3. **Acknowledgement instruction.** Hook output is injected as
|
// command, which an agent reacting to hook output cannot run; the
|
||||||
// developer-role context, not a chat turn, so the user never sees the
|
// hook-admin.mjs invocation is runnable as-is and keeps agents out of
|
||||||
// raw envelope. Asking the model to surface the resolution in its
|
// hand-editing config.json.
|
||||||
// reply is the cheapest way to make the feedback loop visible.
|
// 4. **Honest provenance.** The --reason is the audit trail; "user
|
||||||
function directiveFooter(display, opts = {}) {
|
// confirmed" appears only when the user actually did.
|
||||||
// Offer the rule-scoped-to-file form first. `ignore-file` silences every rule
|
// 5. **Acknowledgement instruction.** Hook output is injected as
|
||||||
// for the path forever, which is far more than one noisy rule on a real UI
|
// developer-role context, so the reply is where the user sees the
|
||||||
// surface justifies, and it was previously the only option named here.
|
// resolution, including any ignore the agent persisted.
|
||||||
const target = opts.grouped ? '<path>' : quoteCommandArg(display);
|
// 6. **Once per session.** The full policy emits on the session's first
|
||||||
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)`;
|
// 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 [
|
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.',
|
'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.',
|
||||||
'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.',
|
`- 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.',
|
||||||
`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.`,
|
`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');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1845,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist only when the write is earned: fresh findings justify creating
|
// The session notice flags mutate the cache, so they must settle before
|
||||||
// `.impeccable/` (dedup and suppression need it), deferred findings do
|
// the persist that makes them stick across events.
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (freshGroups.length > 0) {
|
if (freshGroups.length > 0) {
|
||||||
const firstGroup = freshGroups[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);
|
const allFindings = freshGroups.flatMap((group) => group.findings);
|
||||||
return {
|
return {
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
@@ -1881,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) {
|
if (detectorThrewAny && !pendingWinner && !cleanWinner) {
|
||||||
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
|
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
|
||||||
}
|
}
|
||||||
@@ -1889,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|||||||
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
|
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
|
if (ack?.kind === 'pending') {
|
||||||
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
|
const text = ack.text;
|
||||||
return {
|
return {
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
stdout: payload(text, 'PostToolUse', harness),
|
stdout: payload(text, 'PostToolUse', harness),
|
||||||
@@ -1923,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
|
if (ack?.kind === 'clean') {
|
||||||
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
|
const text = ack.text;
|
||||||
return {
|
return {
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
stdout: payload(text, 'PostToolUse', harness),
|
stdout: payload(text, 'PostToolUse', harness),
|
||||||
@@ -2108,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
|||||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
// A per-edit fire earlier in this session already consumed the footer
|
||||||
// unless new issues appear.
|
// flag, so the Stop wall of text carries the one-line short footer.
|
||||||
persistCache(projectCwd, cache);
|
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 {
|
return {
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
stdout: payload(text, 'Stop', harness),
|
stdout: payload(text, 'Stop', harness),
|
||||||
|
|||||||
@@ -206,10 +206,10 @@ function parseIgnoreColor(value) {
|
|||||||
if (rgb) {
|
if (rgb) {
|
||||||
const parts = splitColorArgs(rgb[1]);
|
const parts = splitColorArgs(rgb[1]);
|
||||||
if (parts.length < 3 || parts.length > 4) return null;
|
if (parts.length < 3 || parts.length > 4) return null;
|
||||||
const r = parseRgbChannel(parts[0]);
|
const r = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.rgb);
|
||||||
const g = parseRgbChannel(parts[1]);
|
const g = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.rgb);
|
||||||
const b = parseRgbChannel(parts[2]);
|
const b = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.rgb);
|
||||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
|
||||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||||
return { r, g, b, a };
|
return { r, g, b, a };
|
||||||
}
|
}
|
||||||
@@ -218,10 +218,10 @@ function parseIgnoreColor(value) {
|
|||||||
if (hsl) {
|
if (hsl) {
|
||||||
const parts = splitColorArgs(hsl[1]);
|
const parts = splitColorArgs(hsl[1]);
|
||||||
if (parts.length < 3 || parts.length > 4) return null;
|
if (parts.length < 3 || parts.length > 4) return null;
|
||||||
const h = parseHueChannel(parts[0]);
|
const h = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.hue);
|
||||||
const s = parsePercentChannel(parts[1]);
|
const s = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.percent);
|
||||||
const l = parsePercentChannel(parts[2]);
|
const l = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.percent);
|
||||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
|
||||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||||
return hslToRgb(h, s, l, a);
|
return hslToRgb(h, s, l, a);
|
||||||
}
|
}
|
||||||
@@ -230,18 +230,13 @@ function parseIgnoreColor(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseHexIgnoreColor(hex) {
|
function parseHexIgnoreColor(hex) {
|
||||||
if (hex.length === 3 || hex.length === 4) {
|
const expanded = hex.length <= 4
|
||||||
const r = parseInt(hex[0] + hex[0], 16);
|
? [...hex].map((digit) => digit.repeat(2)).join('')
|
||||||
const g = parseInt(hex[1] + hex[1], 16);
|
: hex;
|
||||||
const b = parseInt(hex[2] + hex[2], 16);
|
const [r, g, b, alpha = 255] = expanded
|
||||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
.match(/../g)
|
||||||
return { r, g, b, a };
|
.map((channel) => Number.parseInt(channel, 16));
|
||||||
}
|
return { r, g, b, a: alpha / 255 };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitColorArgs(body) {
|
function splitColorArgs(body) {
|
||||||
@@ -259,47 +254,34 @@ function splitColorArgs(body) {
|
|||||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRgbChannel(raw) {
|
const CSS_NUMBER_RE = /^(-?\d*\.?\d+)(%|deg|rad|turn|grad)?$/;
|
||||||
const text = String(raw || '').trim();
|
const identity = (value) => value;
|
||||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
const COLOR_CHANNEL_FORMATS = {
|
||||||
if (!match) return null;
|
rgb: { units: { '': identity, '%': (value) => value * 2.55 }, min: 0, max: 255, round: true },
|
||||||
const value = Number.parseFloat(match[1]);
|
alpha: { units: { '': identity, '%': (value) => value / 100 }, min: 0, max: 1 },
|
||||||
if (!Number.isFinite(value)) return null;
|
hue: {
|
||||||
const scaled = match[2] ? value * 2.55 : value;
|
units: {
|
||||||
if (scaled < 0 || scaled > 255) return null;
|
'': identity,
|
||||||
return Math.round(scaled);
|
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 text = String(raw || '').trim();
|
||||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
const match = text.match(CSS_NUMBER_RE);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const value = Number.parseFloat(match[1]);
|
const convert = units[match[2] || ''];
|
||||||
if (!Number.isFinite(value)) return null;
|
if (!convert) return null;
|
||||||
const alpha = match[2] ? value / 100 : value;
|
const number = Number.parseFloat(match[1]);
|
||||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
if (!Number.isFinite(number)) return null;
|
||||||
}
|
const value = convert(number);
|
||||||
|
if (value < min || value > max) return null;
|
||||||
function parseHueChannel(raw) {
|
return round ? Math.round(value) : value;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* within the first ~300 characters — catches non-git projects.
|
* 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 fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
@@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) {
|
|||||||
|
|
||||||
function isGitIgnored(absPath, cwd) {
|
function isGitIgnored(absPath, cwd) {
|
||||||
try {
|
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,
|
cwd,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/;
|
|||||||
// * bundle-relative: node ".agents/.../hook.mjs"
|
// * bundle-relative: node ".agents/.../hook.mjs"
|
||||||
// * legacy unquoted: node .claude/.../hook.mjs
|
// * legacy unquoted: node .claude/.../hook.mjs
|
||||||
// * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical)
|
// * 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"
|
// * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs"
|
||||||
// A quoted path wins; the guard's two occurrences are identical, so the first
|
// 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-
|
// quoted match is the path. Otherwise fall back to the whitespace/metachar-
|
||||||
@@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) {
|
|||||||
if (!HOOK_MARKER.test(str)) return null;
|
if (!HOOK_MARKER.test(str)) return null;
|
||||||
const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/);
|
const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/);
|
||||||
if (quoted) return quoted[1];
|
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)/);
|
const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/);
|
||||||
return bare ? bare[1] : null;
|
return bare ? bare[1] : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,18 +47,33 @@ import {
|
|||||||
|
|
||||||
// Top-level keys any reader honors: `hook` and `detector` subtrees (hook-lib's
|
// Top-level keys any reader honors: `hook` and `detector` subtrees (hook-lib's
|
||||||
// readConfig), `updateCheck` (context.mjs), `projectRoots` (context.mjs's
|
// readConfig), `updateCheck` (context.mjs), `projectRoots` (context.mjs's
|
||||||
// monorepo resolution), plus `stalenessCheck` below. `$schema` and `version`
|
// monorepo resolution), `buildPath` (context.mjs's build-path directive), plus
|
||||||
// are allowed as conventional metadata nobody reads.
|
// `stalenessCheck` below. `$schema` and `version` are allowed as conventional
|
||||||
|
// metadata nobody reads.
|
||||||
const KNOWN_CONFIG_KEYS = new Set([
|
const KNOWN_CONFIG_KEYS = new Set([
|
||||||
'hook',
|
'hook',
|
||||||
'detector',
|
'detector',
|
||||||
'updateCheck',
|
'updateCheck',
|
||||||
'stalenessCheck',
|
'stalenessCheck',
|
||||||
'projectRoots',
|
'projectRoots',
|
||||||
|
'buildPath',
|
||||||
'$schema',
|
'$schema',
|
||||||
'version',
|
'version',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// The only two values context.mjs and new-work honor. A near miss reads as a
|
||||||
|
// working preference and silently rides the opposite path, so it is worth
|
||||||
|
// reporting rather than coercing.
|
||||||
|
const BUILD_PATH_VALUES = Object.freeze(['comp', 'code']);
|
||||||
|
|
||||||
|
// Evidence that this project does the kind of work `buildPath` governs. A
|
||||||
|
// project that only ever ran polish or audit has no use for the setting and
|
||||||
|
// should never be told it exists. Two stats, so Tier 1 can afford it.
|
||||||
|
const DIRECTION_WORK_PATHS = Object.freeze([
|
||||||
|
path.join('.impeccable', 'surfaces'),
|
||||||
|
path.join('.impeccable', 'mocks', 'decision'),
|
||||||
|
]);
|
||||||
|
|
||||||
// `detector` is a closed set, so a typo here is worth reporting. `hook` is not
|
// `detector` is a closed set, so a typo here is worth reporting. `hook` is not
|
||||||
// checked: it carries runtime settings from several writers and the false
|
// checked: it carries runtime settings from several writers and the false
|
||||||
// positive rate would outweigh the catch.
|
// positive rate would outweigh the catch.
|
||||||
@@ -325,6 +340,20 @@ export function checkConfig({ projectRoot, repoRoot }) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(raw, 'buildPath')
|
||||||
|
&& !BUILD_PATH_VALUES.includes(raw.buildPath)) {
|
||||||
|
findings.push(finding({
|
||||||
|
id: 'config-invalid-build-path',
|
||||||
|
artifact: 'config.json',
|
||||||
|
filePath: rel,
|
||||||
|
severity: 'mention',
|
||||||
|
summary: `${rel} sets \`buildPath\` to ${JSON.stringify(raw.buildPath)}, which nothing reads. `
|
||||||
|
+ `The values are ${BUILD_PATH_VALUES.map((value) => `\`${value}\``).join(' and ')}.`,
|
||||||
|
fix: 'Report the value. An unread `buildPath` does not fall back to the other path; '
|
||||||
|
+ 'it falls back to the default, so a project meaning `code` has been building comp-led.',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const detector = raw.detector;
|
const detector = raw.detector;
|
||||||
if (detector && typeof detector === 'object' && !Array.isArray(detector)) {
|
if (detector && typeof detector === 'object' && !Array.isArray(detector)) {
|
||||||
const unknownDetector = Object.keys(detector).filter((key) => !KNOWN_DETECTOR_KEYS.has(key));
|
const unknownDetector = Object.keys(detector).filter((key) => !KNOWN_DETECTOR_KEYS.has(key));
|
||||||
@@ -345,6 +374,47 @@ export function checkConfig({ projectRoot, repoRoot }) {
|
|||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No recorded build-path preference on a project that plainly does visual
|
||||||
|
* direction work. Not drift in the usual sense: the setting is newer than the
|
||||||
|
* project, so every project that predates it lands here at once. That is why
|
||||||
|
* it is gated twice, on a product record and on evidence of the work the
|
||||||
|
* setting governs, and why it says the choice rather than assuming a harness
|
||||||
|
* can make it. Image generation is the real precondition and this module
|
||||||
|
* cannot see it: a harness-native image tool leaves no trace on disk, so the
|
||||||
|
* finding hands the question to the one reader that knows.
|
||||||
|
*/
|
||||||
|
export function checkBuildPathUnset({ projectRoot, repoRoot, product }) {
|
||||||
|
if (!projectRoot || !product) return [];
|
||||||
|
const roots = [...new Set([projectRoot, repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
for (const name of ['config.json', 'config.local.json']) {
|
||||||
|
const raw = readJson(path.join(root, '.impeccable', name));
|
||||||
|
// Any declared value ends this, valid or not: an invalid one already has
|
||||||
|
// its own finding and two reports of one key is noise.
|
||||||
|
if (raw && Object.prototype.hasOwnProperty.call(raw, 'buildPath')) return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const evidence = DIRECTION_WORK_PATHS.filter((rel) => fs.existsSync(path.join(projectRoot, rel)));
|
||||||
|
if (!evidence.length) return [];
|
||||||
|
|
||||||
|
return [finding({
|
||||||
|
id: 'config-build-path-unset',
|
||||||
|
artifact: 'config.json',
|
||||||
|
filePath: '.impeccable/config.json',
|
||||||
|
severity: 'mention',
|
||||||
|
summary: 'This project has run visual direction work but records no `buildPath`, '
|
||||||
|
+ 'so every direction round takes the comp-first default without anyone having chosen it.',
|
||||||
|
fix: 'Only when image generation exists in your tool surface, offer the choice once: '
|
||||||
|
+ '**comp-first** (an image sets the bar before any code; bolder composition, slower) or '
|
||||||
|
+ '**code-first** (build directly; ambition carried by the direction contract; leaner, faster). '
|
||||||
|
+ 'Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, '
|
||||||
|
+ 'merging with the keys already there. Without image generation there is no choice to record: stay silent.',
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Surface briefs ────────────────────────────────────────────────────────
|
// ─── Surface briefs ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -446,6 +516,7 @@ export function collectBootFindings(ctx, extras = {}) {
|
|||||||
projectRoot,
|
projectRoot,
|
||||||
}),
|
}),
|
||||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||||
|
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||||
...(extras.projectRootPatterns
|
...(extras.projectRootPatterns
|
||||||
? checkProjectRoots({
|
? checkProjectRoots({
|
||||||
|
|||||||
@@ -170,51 +170,35 @@ Output (JSON):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (svelteComponentManifest) {
|
if (svelteComponentManifest) {
|
||||||
if (isDiscard) {
|
const { sourceFile, componentDir } = svelteComponentManifest;
|
||||||
let result;
|
const resultContext = {
|
||||||
try {
|
file: sourceFile,
|
||||||
result = withSourceLockSync(
|
...(isDiscard ? { carbonize: false } : { sourceFile }),
|
||||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
previewMode: 'svelte-component',
|
||||||
'discard:' + id,
|
componentDir,
|
||||||
() => {
|
};
|
||||||
removeSvelteComponentSession(id, process.cwd());
|
const runOperation = isDiscard
|
||||||
return { handled: true };
|
? () => {
|
||||||
},
|
removeSvelteComponentSession(id, process.cwd());
|
||||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
return { handled: true, ...resultContext };
|
||||||
);
|
}
|
||||||
} catch (err) {
|
: () => inlineSvelteComponentAccept(
|
||||||
result = operationFailure(err);
|
|
||||||
}
|
|
||||||
emitResult({
|
|
||||||
...result,
|
|
||||||
file: svelteComponentManifest.sourceFile,
|
|
||||||
carbonize: false,
|
|
||||||
previewMode: 'svelte-component',
|
|
||||||
componentDir: svelteComponentManifest.componentDir,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = withSourceLockSync(
|
|
||||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
|
||||||
'accept:' + id,
|
|
||||||
() => inlineSvelteComponentAccept(
|
|
||||||
svelteComponentManifest,
|
svelteComponentManifest,
|
||||||
variantNum,
|
variantNum,
|
||||||
paramValues,
|
paramValues,
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
),
|
);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = withSourceLockSync(
|
||||||
|
path.resolve(process.cwd(), sourceFile),
|
||||||
|
requestedOperation + ':' + id,
|
||||||
|
runOperation,
|
||||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
result = operationFailure(err, {
|
result = operationFailure(err, resultContext);
|
||||||
file: svelteComponentManifest.sourceFile,
|
|
||||||
sourceFile: svelteComponentManifest.sourceFile,
|
|
||||||
previewMode: 'svelte-component',
|
|
||||||
componentDir: svelteComponentManifest.componentDir,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (result.carbonize) {
|
if (result.carbonize) {
|
||||||
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
|
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
|
||||||
|
|||||||
@@ -97,23 +97,20 @@
|
|||||||
return { value: c.value, label: c.label };
|
return { value: c.value, label: c.label };
|
||||||
});
|
});
|
||||||
|
|
||||||
const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions'];
|
// The Live chrome inventory (which surfaces exist, and the element ids each
|
||||||
const LIVE_UI_SURFACES = [
|
// one owns) comes from the canonical source, skill/scripts/live/ui-surfaces.mjs,
|
||||||
{ 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'] },
|
// which the /live.js assembler serializes into these globals alongside the
|
||||||
{ key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] },
|
// token/port/vocabulary. This file is served raw and injected as a classic
|
||||||
{ key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] },
|
// script, so it cannot import that module; the private impeccable-site repo
|
||||||
{ key: 'action-picker', ids: [PREFIX + '-picker'] },
|
// imports it directly to check its Live UI lab holds a snapshot for every
|
||||||
{ key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] },
|
// surface, which only works while the list has exactly one definition.
|
||||||
{ key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] },
|
// Add a surface in ui-surfaces.mjs, not here.
|
||||||
{ key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] },
|
const LIVE_CHROME_MOUNT_CONTRACT = Array.isArray(window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__)
|
||||||
{ key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] },
|
? window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__
|
||||||
{ key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] },
|
: ['root', 'transport', 'state', 'actions'];
|
||||||
{ 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'] },
|
const LIVE_UI_SURFACES = Array.isArray(window.__IMPECCABLE_LIVE_UI_SURFACES__)
|
||||||
{ key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] },
|
? window.__IMPECCABLE_LIVE_UI_SURFACES__
|
||||||
{ 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'] },
|
|
||||||
];
|
|
||||||
const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))];
|
const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))];
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import path from 'node:path';
|
|||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||||
|
const BATCH_OP_TEXT_LIMIT = 240;
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
||||||
const repairLines = batch?.repair ? [
|
const compactBatch = compactBatchForPrompt(batch);
|
||||||
|
const repairLines = compactBatch.repair ? [
|
||||||
'',
|
'',
|
||||||
'Repair mode:',
|
'Repair mode:',
|
||||||
'- The previous Apply attempt changed source, but validation failed.',
|
'- 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.',
|
'- 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.',
|
'- Keep failed and notes as arrays.',
|
||||||
'- Return the same canonical JSON shape after repair.',
|
'- Return the same canonical JSON shape after repair.',
|
||||||
JSON.stringify(batch.repair, null, 2),
|
JSON.stringify(compactBatch.repair, null, 2),
|
||||||
] : [];
|
] : [];
|
||||||
return [
|
return [
|
||||||
'You are the Impeccable staged copy-edit batch applier.',
|
'You are the Impeccable staged copy-edit batch applier.',
|
||||||
@@ -80,7 +82,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
|||||||
...repairLines,
|
...repairLines,
|
||||||
'',
|
'',
|
||||||
'Staged copy-edit batch:',
|
'Staged copy-edit batch:',
|
||||||
JSON.stringify(compactBatchForPrompt(batch), null, 2),
|
JSON.stringify(compactBatch, null, 2),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +294,7 @@ function readManualEditValidationScript(cwd) {
|
|||||||
function compactBatchForPrompt(batch) {
|
function compactBatchForPrompt(batch) {
|
||||||
return {
|
return {
|
||||||
pageUrl: batch?.pageUrl || null,
|
pageUrl: batch?.pageUrl || null,
|
||||||
repair: batch?.repair || undefined,
|
repair: compactBatchRepair(batch?.repair),
|
||||||
entries: (batch?.entries || []).map((entry) => ({
|
entries: (batch?.entries || []).map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
pageUrl: entry.pageUrl,
|
pageUrl: entry.pageUrl,
|
||||||
@@ -300,7 +302,71 @@ function compactBatchForPrompt(batch) {
|
|||||||
element: compactContextForBatch(entry.element),
|
element: compactContextForBatch(entry.element),
|
||||||
ops: (entry.ops || []).map(compactBatchOp),
|
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,
|
contextRef: op.contextRef,
|
||||||
tag: op.tag,
|
tag: op.tag,
|
||||||
elementId: op.elementId,
|
elementId: op.elementId,
|
||||||
classes: op.classes,
|
classes: compactBatchStringList(op.classes, 24),
|
||||||
originalText: op.originalText,
|
originalText: op.originalText,
|
||||||
newText: op.newText,
|
newText: op.newText,
|
||||||
deleted: op.deleted === true || undefined,
|
deleted: op.deleted === true || undefined,
|
||||||
sourceHint: op.sourceHint,
|
sourceHint: normalizeBatchSourceHint(op.sourceHint),
|
||||||
leaf: compactContextForBatch(op.leaf),
|
leaf: compactContextForBatch(op.leaf),
|
||||||
nearbyEditableTexts: Array.isArray(op.nearbyEditableTexts) ? op.nearbyEditableTexts.slice(0, 8) : [],
|
nearbyEditableTexts: compactNearbyBatchTexts(op.nearbyEditableTexts),
|
||||||
container: compactContextForBatch(op.container),
|
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) {
|
function compactContextForBatch(value) {
|
||||||
if (!value || typeof value !== 'object') return value || null;
|
if (!value || typeof value !== 'object') return value || null;
|
||||||
return {
|
return {
|
||||||
ref: value.ref,
|
ref: compactBatchString(value.ref),
|
||||||
tagName: value.tagName,
|
tagName: compactBatchString(value.tagName),
|
||||||
id: value.id,
|
id: compactBatchString(value.id),
|
||||||
classes: value.classes,
|
classes: compactBatchStringList(value.classes, 24),
|
||||||
textContent: truncate(value.textContent, 900),
|
textContent: truncate(value.textContent, 900),
|
||||||
outerHTML: truncate(stripLiveRuntimeHtml(value.outerHTML), 1800),
|
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) {
|
if (env.IMPECCABLE_LIVE_COPY_AGENT_MODEL) {
|
||||||
args.push('--model', 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
|
// 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
|
// through. On macOS, `claude /login` stores creds in the Keychain, which a
|
||||||
// non-TTY subprocess cannot read; setting CLAUDE_CODE_OAUTH_TOKEN (via
|
// non-TTY subprocess cannot read; setting CLAUDE_CODE_OAUTH_TOKEN (via
|
||||||
// `claude setup-token`) is the supported headless auth path.
|
// `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 }) {
|
function runAgentProcess(command, args, stdin, { cwd, env, logPath, timeoutMs, mirrorOutputPath }) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* node live.mjs --help
|
* node live.mjs --help
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -316,11 +316,17 @@ function globToRegex(pattern) {
|
|||||||
|
|
||||||
function runScript(name, args, options = {}) {
|
function runScript(name, args, options = {}) {
|
||||||
const scriptPath = path.join(__dirname, name);
|
const scriptPath = path.join(__dirname, name);
|
||||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
|
||||||
try {
|
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) {
|
} 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 || '';
|
return err.stdout || err.message || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
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([
|
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
|
||||||
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
|
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
|
||||||
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.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 =
|
const prelude =
|
||||||
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
||||||
`window.__IMPECCABLE_PORT__ = ${port};\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` +
|
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
|
||||||
// Canonical command vocabulary (values + labels + icons). live-browser.js
|
// Canonical command vocabulary (values + labels + icons). live-browser.js
|
||||||
// builds its action picker from this instead of an inline copy.
|
// 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 body = parts.map((part) => {
|
||||||
const file = part.file || path.basename(part.path || '');
|
const file = part.file || path.basename(part.path || '');
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
]);
|
||||||
@@ -22,6 +22,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
// All known harness directories
|
// All known harness directories
|
||||||
const HARNESS_DIRS = [
|
const HARNESS_DIRS = [
|
||||||
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
|
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
|
||||||
|
'.hermes',
|
||||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -29,28 +29,53 @@
|
|||||||
* "materials": ["letterpress", "newsprint"], // optional, rendered as tags
|
* "materials": ["letterpress", "newsprint"], // optional, rendered as tags
|
||||||
* "viewport": "one line: the first-viewport composition", // optional
|
* "viewport": "one line: the first-viewport composition", // optional
|
||||||
* "case": "one line: the fusion verdict, honest", // 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
|
* "risk": "one line: the honest risk", // optional
|
||||||
* "body": "fallback prose when the structured fields are absent",
|
* "body": "fallback prose when the structured fields are absent",
|
||||||
* "sketch": ".impeccable/sketches/assigned.webp", // optional; may not exist
|
* "comp": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
|
||||||
* // yet: the page shimmer-waits and polls the
|
* // full-fidelity direction comp (the legacy
|
||||||
* // slot until the file lands, so serve first
|
* // key "sketch" is accepted as an alias). May
|
||||||
* // and generate after
|
* // 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;
|
* "hero": "https://... or /abs/path.webp", // optional inspiration image;
|
||||||
* // rides picture-in-picture when a sketch exists
|
* // rides picture-in-picture when a comp exists
|
||||||
* "board": "https://... or /abs/path.webp" // optional secondary image
|
* "board": "https://... or /abs/path.webp" // optional secondary image
|
||||||
* }, ...
|
* }, ...
|
||||||
* ],
|
* ],
|
||||||
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
|
* "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;
|
* "canon": true, // adds the "Play it straight" standing exit;
|
||||||
* // direction rounds only (returns {"optionId":"canon"})
|
* // direction rounds only (returns {"optionId":"canon"})
|
||||||
* "canonCard": { ... }, // optional: the standing exit as a full card with the
|
* "canonCard": { ... }, // optional: the standing exit as a full card with the
|
||||||
* // same anatomy (label, thesis, palette, sketch, ...);
|
* // same anatomy (label, thesis, palette, comp, ...);
|
||||||
* // rendered last and visually subordinate. Without it,
|
* // rendered last and visually subordinate. Without it,
|
||||||
* // canon stays a quiet footer action.
|
* // 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
|
* Options render as large cards: the comp leads when present, with the
|
||||||
* inspiration image picture-in-picture; a hero alone renders full-bleed; a
|
* inspiration image picture-in-picture; a hero alone renders full-bleed; a
|
||||||
* text-only direction gets its identity from the palette chips and tags.
|
* text-only direction gets its identity from the palette chips and tags.
|
||||||
* Local image paths are served by this server; nothing is uploaded anywhere.
|
* Local image paths are served by this server; nothing is uploaded anywhere.
|
||||||
@@ -123,12 +148,30 @@ function printAnswer(raw) {
|
|||||||
if (a.hero || a.board) {
|
if (a.hero || a.board) {
|
||||||
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.");
|
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) {
|
if (a.comp) {
|
||||||
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') {
|
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.');
|
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.');
|
||||||
|
}
|
||||||
|
if (a.buildPath === 'comp' || a.buildPath === 'code') {
|
||||||
|
// The page never writes the flip itself, but "never write it" overstated
|
||||||
|
// that into a rule the agent then applied to new-work's one-time offer,
|
||||||
|
// which exists for exactly this case: a flip on a project that had no
|
||||||
|
// recorded default is the only moment the preference is ever asked for.
|
||||||
|
const origin = a.buildPathFlipped
|
||||||
|
? 'flipped on the page, so it binds this session only, and the page never writes it back; the sole exception is new-work’s one-time offer, on a project that had no recorded default at all, which asks after the round closes and writes the answer to .impeccable/config.json'
|
||||||
|
: 'the round’s recorded default';
|
||||||
|
console.log(`BUILD PATH: ${a.buildPath} (${origin}). ${a.buildPath === 'comp'
|
||||||
|
? 'Comp-led: the chosen card’s comp is law; generate it before building when it does not exist yet, and the finish review audits the build against it.'
|
||||||
|
: 'Code-led: no comp is owed; a comp that already rendered rides at the finish review as the critique reference, and the ambition lives in the direction contract.'}`);
|
||||||
|
}
|
||||||
} catch { /* raw answer */ }
|
} catch { /* raw answer */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,21 +181,29 @@ const portArg = Number(arg('port', '0'));
|
|||||||
const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions');
|
const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions');
|
||||||
const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`);
|
const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`);
|
||||||
const answerFile = (key) => path.join(QUESTION_DIR, `${key}.answer.json`);
|
const answerFile = (key) => path.join(QUESTION_DIR, `${key}.answer.json`);
|
||||||
|
// A code-to-comp flip mid-round: the page records it here and --wait
|
||||||
|
// surfaces it as its own event, because the agent must start generating
|
||||||
|
// comps while the round is still open. Comp-to-code needs no event; it is
|
||||||
|
// free and rides the final ANSWER.
|
||||||
|
const flipFile = (key) => path.join(QUESTION_DIR, `${key}.flip.json`);
|
||||||
|
|
||||||
if (hasFlag('schema')) {
|
if (hasFlag('schema')) {
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
title: 'Choose the visual world',
|
title: 'Choose the visual world',
|
||||||
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
|
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
|
||||||
options: [
|
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/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.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.' }], comp: '.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: '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: 'model-pick', label: 'The Broadside Ballad', kicker: 'IMPECCABLE’S 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.', comp: '.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.', comp: '.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'] },
|
||||||
|
buildPath: { value: 'comp', toggle: true },
|
||||||
canon: true,
|
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.', comp: '.impeccable/mocks/decision/canon.webp' },
|
||||||
steer: true,
|
steer: true,
|
||||||
}, null, 2));
|
}, 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. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. 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/comp accept URLs or local paths; comp 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 comp, 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 comp (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 "IMPECCABLE’S 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. A card may instead declare "wireframe" ({"cols":12,"rows":10,"regions":[{"label":"nav rail","x":0,"y":0,"w":3,"h":10,"accent":true}]}): the page draws it as a layout schematic in the media slot; surface-scope rounds use it on code-led builds, it never counts toward salience, and the card keeps its full read on the front. The comp slot carries the card’s full-fidelity direction comp (the legacy key "sketch" is accepted as an alias). 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. buildPath rides the payload as { "value": "comp"|"code", "toggle": true }: the value is the recorded default (.impeccable/config.json buildPath, or .impeccable/config.local.json where one machine differs) and the toggle renders a footer switch whose flip binds that session only; the ANSWER then carries buildPath plus buildPathFlipped. On a code-led round each card still declares its comp path as a flip reserve: wireframes render, and a flip to comp makes --wait return once with BUILD PATH FLIPPED so you generate the comps into the declared slots while the round stays open; a flip back to code is free, and a comp that already landed stays as the critique reference. The toggle may only be offered when image generation exists: a harness with no image tool and no API key never sets toggle: true, so the choice never renders where comps cannot be made, and code-led simply rides as the untoggleable value. followup: true keeps the table open after a pick for a second round via --update; send the next payload immediately, the page is waiting on it.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +230,13 @@ if (hasFlag('wait')) {
|
|||||||
let sawClose = false;
|
let sawClose = false;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (answered()) break;
|
if (answered()) break;
|
||||||
|
// A build-path flip is its own event, not an answer: the round stays
|
||||||
|
// open, and the agent's job right now is comps, not code.
|
||||||
|
if (fs.existsSync(flipFile(key))) {
|
||||||
|
try { fs.rmSync(flipFile(key)); } catch { /* consumed elsewhere */ }
|
||||||
|
console.log('BUILD PATH FLIPPED: comp (for this session only; never write it to settings). The table is still open and the page shows shimmer where the images will land: generate each open card’s comp into its declared path now, lead first, then collect the answer with --wait again. A card whose comp already exists needs nothing.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
if (!alive()) {
|
if (!alive()) {
|
||||||
console.log('serve-question: the question server is gone with no answer. This is a server failure, not a user decision: restart it with --start and the same payload, reopen the URL for the user, and wait again. Never proceed without their choice while their browser session is open.');
|
console.log('serve-question: the question server is gone with no answer. This is a server failure, not a user decision: restart it with --start and the same payload, reopen the URL for the user, and wait again. Never proceed without their choice while their browser session is open.');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
@@ -196,12 +254,16 @@ if (hasFlag('wait')) {
|
|||||||
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
|
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();
|
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
|
||||||
printAnswer(collected);
|
printAnswer(collected);
|
||||||
// A re-roll keeps the table open: the server stays alive awaiting --update,
|
// A re-roll or a followup-round pick keeps the table open: the server stays
|
||||||
// so only the answer file is consumed. Terminal choices clean up fully.
|
// alive awaiting --update, so only the answer file is consumed. Terminal
|
||||||
let isRerollAnswer = false;
|
// choices clean up fully.
|
||||||
try { isRerollAnswer = JSON.parse(collected).optionId === 'reroll'; } catch { /* treat as terminal */ }
|
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 */ }
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +332,12 @@ else raw = fs.readFileSync(0, 'utf8');
|
|||||||
let payload;
|
let payload;
|
||||||
let options;
|
let options;
|
||||||
let localImages = [];
|
let localImages = [];
|
||||||
|
// Build path (comp-led vs code-led): the payload carries the recorded
|
||||||
|
// default; the page's toggle updates the live value per session. The server
|
||||||
|
// owns both so the final ANSWER states the path and whether it was flipped
|
||||||
|
// even when the round never rendered a toggle.
|
||||||
|
let buildPathDefault = null;
|
||||||
|
let liveBuildPath = null;
|
||||||
|
|
||||||
function loadRound(json) {
|
function loadRound(json) {
|
||||||
const parsed = JSON.parse(json);
|
const parsed = JSON.parse(json);
|
||||||
@@ -285,10 +353,10 @@ function loadRound(json) {
|
|||||||
localImages.push(abs);
|
localImages.push(abs);
|
||||||
return `/img/${localImages.length - 1}`;
|
return `/img/${localImages.length - 1}`;
|
||||||
};
|
};
|
||||||
// Sketches stream in after the page is served, so their slots register
|
// Comps stream in after the page is served, so their slots register
|
||||||
// whether or not the file exists yet; /img answers 404 until it lands and
|
// whether or not the file exists yet; /img answers 404 until it lands and
|
||||||
// the page polls the slot. Remote sketch URLs pass through untouched.
|
// the page polls the slot. Remote comp URLs pass through untouched.
|
||||||
const sketchSrc = (value) => {
|
const compSrc = (value) => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
if (/^https?:\/\//.test(value)) return value;
|
if (/^https?:\/\//.test(value)) return value;
|
||||||
localImages.push(path.resolve(value));
|
localImages.push(path.resolve(value));
|
||||||
@@ -299,14 +367,26 @@ function loadRound(json) {
|
|||||||
...option,
|
...option,
|
||||||
heroSrc: imageSrc(option.hero),
|
heroSrc: imageSrc(option.hero),
|
||||||
boardSrc: imageSrc(option.board),
|
boardSrc: imageSrc(option.board),
|
||||||
sketchSrc: sketchSrc(option.sketch),
|
compSrc: compSrc(option.comp ?? option.sketch),
|
||||||
});
|
});
|
||||||
options = parsed.options.map(decorate);
|
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
|
// The standing exit as a full card: same anatomy, reserved id, rendered
|
||||||
// subordinate by the page. Without it, canon stays the quiet footer action.
|
// subordinate by the page. Without it, canon stays the quiet footer action.
|
||||||
if (parsed.canonCard && typeof parsed.canonCard === 'object') {
|
if (parsed.canonCard && typeof parsed.canonCard === 'object') {
|
||||||
options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
|
options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
|
||||||
}
|
}
|
||||||
|
options = [...options, ...declined];
|
||||||
|
buildPathDefault = (parsed.buildPath && (parsed.buildPath.value === 'comp' || parsed.buildPath.value === 'code'))
|
||||||
|
? { value: parsed.buildPath.value, toggle: parsed.buildPath.toggle === true }
|
||||||
|
: null;
|
||||||
|
liveBuildPath = buildPathDefault?.value ?? null;
|
||||||
}
|
}
|
||||||
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
|
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
|
||||||
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
|
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
|
||||||
@@ -322,7 +402,26 @@ function page() {
|
|||||||
// and material tags give a text-only direction an immediate identity that
|
// and material tags give a text-only direction an immediate identity that
|
||||||
// no generation luck can distort.
|
// 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 fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
|
||||||
const hasMedia = (option) => Boolean(option.sketchSrc || option.heroSrc || option.boardSrc);
|
const demoted = (option) => option.verdict === 'declined';
|
||||||
|
// The build path (comp-led vs code-led) is a workflow preference, not a
|
||||||
|
// design decision: the payload carries the recorded default and whether
|
||||||
|
// the page offers the toggle. On a code-led round a declared comp path is
|
||||||
|
// a flip reserve, not a face: wireframes render, and the slot only starts
|
||||||
|
// shimmering when the user flips to comp.
|
||||||
|
const buildPath = buildPathDefault;
|
||||||
|
const codeLed = buildPath?.value === 'code';
|
||||||
|
// 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].compSrc || options[0].heroSrc || options[0].boardSrc));
|
||||||
|
// A declined card never renders a full media face, comp included: even a
|
||||||
|
// declared comp would buy back the salience the verdict took away.
|
||||||
|
const faceComp = (option) => (demoted(option) || codeLed) ? null : option.compSrc;
|
||||||
|
const thumbOnly = (option) => !faceComp(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
|
||||||
|
const hasMedia = (option) => Boolean(faceComp(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
|
||||||
// The back exists to keep long facts off a card whose front is an image;
|
// 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
|
// 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.
|
// the full read lives on the front instead.
|
||||||
@@ -338,9 +437,34 @@ function page() {
|
|||||||
idBits.push(option.materials.slice(0, 4).map((m) => `<span class="tag">${esc(m)}</span>`).join(''));
|
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>`);
|
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">From ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
|
||||||
|
const raisesHead = (count) => `<div class="raises-head"><span class="fact-label">Improved by Impeccable's worlds</span>${count > 1 ? `<span class="raises-count" data-raises-count>1/${count}</span>` : ''}</div>`;
|
||||||
|
if (raiseLines.length > 1) {
|
||||||
|
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter for the next improvement" aria-label="How Impeccable's worlds improved this direction; activate to see the next improvement">
|
||||||
|
${raisesHead(raiseLines.length)}
|
||||||
|
${raiseLines.join('')}
|
||||||
|
<span class="sr-live" aria-live="polite"></span>
|
||||||
|
</div>`);
|
||||||
|
} else {
|
||||||
|
rows.push(`<div class="raises">${raisesHead(1)}${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
|
// 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
|
// 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.
|
// the card's back; once the comp lands, the first viewport is a picture.
|
||||||
// With no art there is no back, so the full read fills the room the
|
// With no art there is no back, so the full read fills the room the
|
||||||
// image would have taken.
|
// image would have taken.
|
||||||
if (hasMedia(option)) {
|
if (hasMedia(option)) {
|
||||||
@@ -348,6 +472,7 @@ function page() {
|
|||||||
} else {
|
} else {
|
||||||
rows.push(fact('First viewport', option.viewport));
|
rows.push(fact('First viewport', option.viewport));
|
||||||
rows.push(fact('The case', option.case));
|
rows.push(fact('The case', option.case));
|
||||||
|
rows.push(fact('Kept', option.kept));
|
||||||
rows.push(fact('Risk', option.risk));
|
rows.push(fact('Risk', option.risk));
|
||||||
}
|
}
|
||||||
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
|
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
|
||||||
@@ -357,25 +482,32 @@ function page() {
|
|||||||
const backFacts = (option) => [
|
const backFacts = (option) => [
|
||||||
fact('First viewport', option.viewport),
|
fact('First viewport', option.viewport),
|
||||||
fact('The case', option.case),
|
fact('The case', option.case),
|
||||||
|
fact('Kept', option.kept),
|
||||||
fact('Risk', option.risk),
|
fact('Risk', option.risk),
|
||||||
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
|
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
|
||||||
].filter(Boolean).join('\n ');
|
].filter(Boolean).join('\n ');
|
||||||
const media = (option) => {
|
const media = (option) => {
|
||||||
const inspiration = option.heroSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
const inspirationSrc = option.heroSrc || option.boardSrc;
|
||||||
<img src="${esc(option.heroSrc)}" alt="">
|
const inspiration = inspirationSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||||
|
<img src="${esc(inspirationSrc)}" alt="">
|
||||||
<figcaption>inspiration</figcaption>
|
<figcaption>inspiration</figcaption>
|
||||||
</figure>` : '';
|
</figure>` : '';
|
||||||
const details = hasBack(option) ? flipChip('Details') : '';
|
const details = hasBack(option) ? flipChip('Details') : '';
|
||||||
if (option.sketchSrc) {
|
// Thumb-only art renders inside the body via anatomy(), never as a face,
|
||||||
return `<div class="media sketching" data-sketch="${esc(option.sketchSrc)}">
|
// and a declined card's comp slot is ignored outright.
|
||||||
<div class="shimmer"><span class="sketch-note">sketching…</span></div>
|
if (thumbOnly(option)) return '';
|
||||||
<img class="sketch" alt="" hidden>
|
if (faceComp(option)) {
|
||||||
|
const textOnlyFacts = backFacts(option);
|
||||||
|
return `<div class="media comp-pending" data-comp="${esc(option.compSrc)}">
|
||||||
|
<div class="shimmer"><span class="comp-note">rendering…</span></div>
|
||||||
|
<img class="comp" alt="" hidden>
|
||||||
${inspiration}
|
${inspiration}
|
||||||
|
<template class="text-only-facts">${textOnlyFacts}</template>
|
||||||
<div class="chips">${expandChip}${details}</div>
|
<div class="chips">${expandChip}${details}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
if (option.heroSrc || option.boardSrc) {
|
if (option.heroSrc || option.boardSrc) {
|
||||||
// Without a sketch the catalog art is the card's face; it stays a
|
// Without a comp the catalog art is the card's face; it stays a
|
||||||
// labeled reference so it never reads as the promise of the build.
|
// 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.">
|
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="">
|
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||||
@@ -385,17 +517,41 @@ function page() {
|
|||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
// Wireframe media: a code-led card's layout schematic, authored as grid
|
||||||
|
// regions in the payload and drawn by the page; boxes and labels, no art.
|
||||||
|
// It fills the media slot only when the card has no imagery, and it never
|
||||||
|
// counts toward salience or earns a card back: the full read stays on the
|
||||||
|
// front, exactly like a text-only card.
|
||||||
|
const wire = (option) => {
|
||||||
|
const frame = option.wireframe;
|
||||||
|
if (!frame || !Array.isArray(frame.regions) || !frame.regions.length || media(option) || demoted(option)) return '';
|
||||||
|
const cols = Number(frame.cols) > 0 ? Number(frame.cols) : 12;
|
||||||
|
const rows = Number(frame.rows) > 0 ? Number(frame.rows) : 10;
|
||||||
|
const pct = (n, total) => `${Math.max(0, Math.min(100, (n / total) * 100)).toFixed(2)}%`;
|
||||||
|
const cells = frame.regions.slice(0, 12).map((region) => {
|
||||||
|
const x = Number(region.x) || 0;
|
||||||
|
const y = Number(region.y) || 0;
|
||||||
|
const w = Math.max(Number(region.w) || 1, 0.5);
|
||||||
|
const h = Math.max(Number(region.h) || 1, 0.5);
|
||||||
|
return `<div class="wire-region${region.accent ? ' accent' : ''}" style="left:${pct(x, cols)};top:${pct(y, rows)};width:${pct(w, cols)};height:${pct(h, rows)}"><span>${esc(region.label || '')}</span></div>`;
|
||||||
|
}).join('');
|
||||||
|
return `<div class="media wire" role="img" aria-label="Layout schematic">
|
||||||
|
<div class="wire-field">${cells}</div>
|
||||||
|
<p class="media-label">layout</p>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this';
|
||||||
const cards = options.map((option, index) => `
|
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)}"${codeLed && option.compSrc && !demoted(option) ? ` data-comp-slot="${esc(option.compSrc)}"` : ''}>
|
||||||
<div class="card-inner">
|
<div class="card-inner">
|
||||||
<div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
|
<div class="face front${index === 0 ? ' lead' : ''}${(media(option) || wire(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)}
|
${media(option) || wire(option)}
|
||||||
<div class="body">
|
<div class="body">
|
||||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||||
<h2>${esc(option.label)}</h2>
|
<h2>${esc(option.label)}</h2>
|
||||||
${anatomy(option)}
|
${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>
|
||||||
</div>
|
</div>
|
||||||
${hasBack(option) ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
${hasBack(option) ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||||
@@ -406,7 +562,7 @@ function page() {
|
|||||||
<div class="body back-body">
|
<div class="body back-body">
|
||||||
${option.boardSrc ? `<p class="tier">The full read · ${esc(option.label)}</p>` : ''}
|
${option.boardSrc ? `<p class="tier">The full read · ${esc(option.label)}</p>` : ''}
|
||||||
${backFacts(option)}
|
${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>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -439,9 +595,12 @@ function page() {
|
|||||||
--ks-font-display: "Alumni Sans", "Albert Sans", Arial, sans-serif;
|
--ks-font-display: "Alumni Sans", "Albert Sans", Arial, sans-serif;
|
||||||
--ks-font: "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, system-ui, sans-serif;
|
--ks-font: "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, system-ui, sans-serif;
|
||||||
--ks-mono: "SFMono-Regular", "Roboto Mono", "JetBrains Mono", Consolas, monospace;
|
--ks-mono: "SFMono-Regular", "Roboto Mono", "JetBrains Mono", Consolas, monospace;
|
||||||
|
/* One inset shared by the content column, the deck's snap padding, and
|
||||||
|
the sticky footer, so all three align on the same 90rem column. */
|
||||||
|
--page-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2));
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; margin: 0; }
|
* { box-sizing: border-box; margin: 0; }
|
||||||
body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 2rem; min-height: 100dvh; display: flex; flex-direction: column; overflow-x: clip; }
|
body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 0; min-height: 100dvh; display: flex; flex-direction: column; overflow-x: clip; }
|
||||||
#ambient { position: fixed; inset: -40px; z-index: 0; background-size: cover; background-position: center; filter: blur(34px) saturate(1.05); opacity: 0; transition: opacity .55s ease, background-image .2s; pointer-events: none; }
|
#ambient { position: fixed; inset: -40px; z-index: 0; background-size: cover; background-position: center; filter: blur(34px) saturate(1.05); opacity: 0; transition: opacity .55s ease, background-image .2s; pointer-events: none; }
|
||||||
#scrim { position: fixed; inset: 0; z-index: 0; background: linear-gradient(180deg, oklch(7% 0.006 95 / 0.62), oklch(7% 0.006 95 / 0.78)); pointer-events: none; }
|
#scrim { position: fixed; inset: 0; z-index: 0; background: linear-gradient(180deg, oklch(7% 0.006 95 / 0.62), oklch(7% 0.006 95 / 0.78)); pointer-events: none; }
|
||||||
header, main, footer { position: relative; z-index: 1; }
|
header, main, footer { position: relative; z-index: 1; }
|
||||||
@@ -453,7 +612,7 @@ function page() {
|
|||||||
.brand { display: flex; align-items: center; gap: .55rem; color: var(--ks-kinpaku); }
|
.brand { display: flex; align-items: center; gap: .55rem; color: var(--ks-kinpaku); }
|
||||||
.brand svg { width: 22px; height: 22px; }
|
.brand svg { width: 22px; height: 22px; }
|
||||||
.wordmark { font-family: var(--ks-font-display); font-weight: 400; font-size: 1.125rem; letter-spacing: 0.15em; text-transform: uppercase; line-height: 1; color: var(--ks-kinpaku); }
|
.wordmark { font-family: var(--ks-font-display); font-weight: 400; font-size: 1.125rem; letter-spacing: 0.15em; text-transform: uppercase; line-height: 1; color: var(--ks-kinpaku); }
|
||||||
.headline { display: flex; align-items: center; gap: .9rem; }
|
.headline { display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; }
|
||||||
.headline-die { flex: none; width: 34px; height: 34px; color: var(--ks-kinpaku); }
|
.headline-die { flex: none; width: 34px; height: 34px; color: var(--ks-kinpaku); }
|
||||||
h1 { font-family: var(--ks-font-display); font-weight: 100; font-size: clamp(2.6rem, 5vw, 4.2rem); letter-spacing: -0.01em; line-height: 1.02; color: var(--ks-champagne); }
|
h1 { font-family: var(--ks-font-display); font-weight: 100; font-size: clamp(2.6rem, 5vw, 4.2rem); letter-spacing: -0.01em; line-height: 1.02; color: var(--ks-champagne); }
|
||||||
.question { color: var(--ks-text-muted); margin-top: .7rem; max-width: 52rem; }
|
.question { color: var(--ks-text-muted); margin-top: .7rem; max-width: 52rem; }
|
||||||
@@ -465,12 +624,24 @@ function page() {
|
|||||||
.deck-shell { position: relative; width: 100vw; margin-left: calc(50% - 50vw); }
|
.deck-shell { position: relative; width: 100vw; margin-left: calc(50% - 50vw); }
|
||||||
/* One row in a wide viewport, one column in a tall one; the deck scrolls on
|
/* One row in a wide viewport, one column in a tall one; the deck scrolls on
|
||||||
its axis with snap points and the arrows page it card by card. */
|
its axis with snap points and the arrows page it card by card. */
|
||||||
.grid { --deck-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2)); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; }
|
.grid { --deck-inset: var(--page-inset); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; }
|
||||||
.grid::-webkit-scrollbar { display: none; }
|
.grid::-webkit-scrollbar { display: none; }
|
||||||
/* Wide enough that the sketch carries the card: at 27vw the imagery read
|
/* Wide enough that the comp carries the card: at 27vw the imagery read
|
||||||
as a thumbnail above a column of copy, and the copy won the attention
|
as a thumbnail above a column of copy, and the copy won the attention
|
||||||
contest the sketch is supposed to win. */
|
contest the comp is supposed to win. */
|
||||||
.grid > .card { flex: 0 0 clamp(24rem, 34vw, 34rem); scroll-snap-align: center; }
|
.grid > .card { flex: 0 0 clamp(24rem, 34vw, 34rem); scroll-snap-align: center; }
|
||||||
|
/* Short landscape viewports (13-inch laptops): header, a 34vw card, and the
|
||||||
|
footer do not fit 800px of height, so the headline compacts and the deck
|
||||||
|
narrows. Height is the axis that gives; the sticky footer keeps the
|
||||||
|
round's verbs on screen while a too-tall card scrolls. */
|
||||||
|
@media (min-aspect-ratio: 1/1) and (max-height: 900px) {
|
||||||
|
body { padding-top: 1.1rem; }
|
||||||
|
h1 { font-size: clamp(2rem, 3.4vw, 2.9rem); }
|
||||||
|
.question { margin-top: .45rem; }
|
||||||
|
.stage { gap: 1rem; }
|
||||||
|
.grid > .card { flex-basis: clamp(20rem, 27vw, 27rem); }
|
||||||
|
.grid > .card.declined { flex-basis: clamp(13rem, 18vw, 18rem); }
|
||||||
|
}
|
||||||
.nav { position: absolute; z-index: 6; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: oklch(7% 0.006 95 / 0.78); border: 1px solid var(--ks-rule); color: var(--ks-kinpaku); cursor: pointer; backdrop-filter: blur(6px); transition: border-color .2s, color .2s, opacity .2s; }
|
.nav { position: absolute; z-index: 6; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: oklch(7% 0.006 95 / 0.78); border: 1px solid var(--ks-rule); color: var(--ks-kinpaku); cursor: pointer; backdrop-filter: blur(6px); transition: border-color .2s, color .2s, opacity .2s; }
|
||||||
.nav:hover { border-color: var(--ks-kinpaku-deep); color: var(--ks-kinpaku-pale); }
|
.nav:hover { border-color: var(--ks-kinpaku-deep); color: var(--ks-kinpaku-pale); }
|
||||||
.nav[disabled] { opacity: .25; cursor: default; }
|
.nav[disabled] { opacity: .25; cursor: default; }
|
||||||
@@ -497,6 +668,14 @@ function page() {
|
|||||||
.nav.next { right: auto; left: 50%; top: auto; bottom: 6px; transform: translate(-50%, 0); }
|
.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-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); }
|
.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; }
|
||||||
|
/* The sticky bar is a wide-viewport fix. Here it would sit over the
|
||||||
|
deck's More pager and cost a third of a phone screen, and the deck
|
||||||
|
already scrolls internally, so the footer stays in the page flow. */
|
||||||
|
footer { position: static; width: auto; margin: 1rem 0 0; padding: .7rem 0 1.2rem; background: transparent; border-top: 0; backdrop-filter: none; }
|
||||||
}
|
}
|
||||||
.card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
|
.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); }
|
.card:hover { transform: rotate(0deg) translateY(-4px); }
|
||||||
@@ -520,7 +699,7 @@ function page() {
|
|||||||
region entirely instead of reserving a blank 16:9 void. */
|
region entirely instead of reserving a blank 16:9 void. */
|
||||||
.face.text-only .kicker { position: static; align-self: flex-start; margin: 14px 0 0 14px; }
|
.face.text-only .kicker { position: static; align-self: flex-start; margin: 14px 0 0 14px; }
|
||||||
.face.text-only .body { padding-top: 12px; }
|
.face.text-only .body { padding-top: 12px; }
|
||||||
/* 16/10 matches the landscape sketch frame; portrait art overrides the
|
/* 16/10 matches the landscape comp frame; portrait art overrides the
|
||||||
slot with its own exact ratio at load (see the load listener), and the
|
slot with its own exact ratio at load (see the load listener), and the
|
||||||
deck narrows so portrait cards line up side by side. */
|
deck narrows so portrait cards line up side by side. */
|
||||||
.media { position: relative; width: 100%; aspect-ratio: 16/10; flex: none; }
|
.media { position: relative; width: 100%; aspect-ratio: 16/10; flex: none; }
|
||||||
@@ -556,14 +735,14 @@ function page() {
|
|||||||
.body.back-body { overflow-y: auto; flex: 1; scrollbar-width: thin; }
|
.body.back-body { overflow-y: auto; flex: 1; scrollbar-width: thin; }
|
||||||
/* Inspiration rides picture-in-picture: the catalog world explains where the
|
/* Inspiration rides picture-in-picture: the catalog world explains where the
|
||||||
direction comes from without promising what the build will look like. */
|
direction comes from without promising what the build will look like. */
|
||||||
/* Hovering the inspiration takes over the whole media region; the sketch is
|
/* Hovering the inspiration takes over the whole media region; the comp is
|
||||||
the promise, the inspiration is a glance, so the glance must cost nothing. */
|
the promise, the inspiration is a glance, so the glance must cost nothing. */
|
||||||
.pip { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; width: 84px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; background: var(--ks-lacquer); cursor: zoom-in; transition: left .35s cubic-bezier(.16,1,.3,1), bottom .35s cubic-bezier(.16,1,.3,1), width .35s cubic-bezier(.16,1,.3,1), height .35s cubic-bezier(.16,1,.3,1), border-radius .35s ease; box-shadow: 0 6px 18px oklch(0% 0 0 / 0.45); }
|
.pip { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; width: 84px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; background: var(--ks-lacquer); cursor: zoom-in; transition: left .35s cubic-bezier(.16,1,.3,1), bottom .35s cubic-bezier(.16,1,.3,1), width .35s cubic-bezier(.16,1,.3,1), height .35s cubic-bezier(.16,1,.3,1), border-radius .35s ease; box-shadow: 0 6px 18px oklch(0% 0 0 / 0.45); }
|
||||||
.pip img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
.pip img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||||
.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 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; }
|
.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); }
|
.comp-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
|
/* Catalog art standing in for a comp-less card is a reference, and says so
|
||||||
on its face; the same pill later carries "artwork unavailable". */
|
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); }
|
.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
|
/* Art that never arrives collapses to the card's own palette (painted
|
||||||
@@ -575,16 +754,63 @@ function page() {
|
|||||||
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
|
.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; }
|
.media.unavailable .chips { z-index: 2; }
|
||||||
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
||||||
the real sketch whenever it lands. */
|
the real comp whenever it lands. */
|
||||||
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
.media.stand-in img.comp { filter: brightness(.72) saturate(.85); }
|
||||||
.media.stand-in .pip { display: none; }
|
.media.stand-in .pip { display: none; }
|
||||||
.stand-in-label { position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; margin: 0; font-family: var(--ks-mono); font-size: .56rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 4px 0 5px; background: oklch(7% 0.006 95 / 0.78); backdrop-filter: blur(3px); }
|
.stand-in-label { position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; margin: 0; font-family: var(--ks-mono); font-size: .56rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 4px 0 5px; background: oklch(7% 0.006 95 / 0.78); backdrop-filter: blur(3px); }
|
||||||
.media.sketching { position: relative; }
|
.media.comp-pending { position: relative; }
|
||||||
.media.sketching .shimmer { position: absolute; inset: 0; }
|
.media.comp-pending .shimmer { position: absolute; inset: 0; }
|
||||||
.media img.sketch { position: relative; z-index: 1; }
|
.media img.comp { position: relative; z-index: 1; }
|
||||||
/* The generic .media img display:block would defeat [hidden] and float an
|
/* The generic .media img display:block would defeat [hidden] and float an
|
||||||
empty block over the shimmer; an unloaded sketch must truly not render. */
|
empty block over the shimmer; an unloaded comp must truly not render. */
|
||||||
.media img[hidden] { display: none; }
|
.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); }
|
||||||
|
/* Wireframe media: the code-led schematic. Quiet boxes in the card's own
|
||||||
|
chrome; uniform salience across cards by construction, so it needs no
|
||||||
|
parity rules. */
|
||||||
|
.media.wire { background: var(--ks-lacquer); border-bottom: 1px solid var(--ks-rule); }
|
||||||
|
.wire-field { position: absolute; inset: 12px 12px 26px; }
|
||||||
|
.wire-region { position: absolute; border: 1px solid oklch(78% 0 0 / 0.26); border-radius: 3px; background: oklch(78% 0 0 / 0.05); display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
||||||
|
.wire-region span { font-family: var(--ks-mono); font-size: .55rem; letter-spacing: .1em; text-transform: uppercase; color: var(--ks-text-faint); text-align: center; padding: 2px 4px; }
|
||||||
|
.wire-region.accent { border-color: oklch(84% 0.19 80.46 / 0.5); background: oklch(84% 0.19 80.46 / 0.06); }
|
||||||
|
.wire-region.accent span { color: var(--ks-kinpaku-rich); }
|
||||||
|
/* 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 improvements the dealt worlds donated to the assigned
|
||||||
|
direction, each named for its donor world. Patina, not kinpaku:
|
||||||
|
provenance, not a call to action. A quiet contained panel, never an
|
||||||
|
accent side-tab. */
|
||||||
|
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; padding: 7px 10px 8px; background: oklch(70% 0.12 188 / 0.06); border: 1px solid oklch(70% 0.12 188 / 0.22); border-radius: 8px; }
|
||||||
|
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; }
|
||||||
|
.raise .fact-label { color: var(--ks-patina); }
|
||||||
|
/* Several kept ideas cycle instead of stacking: one visible at a time, a
|
||||||
|
counter for the rest, the whole block advances on click. */
|
||||||
|
.raises-cycle { cursor: pointer; transition: border-color .2s ease; }
|
||||||
|
.raises-cycle:hover { border-color: oklch(70% 0.12 188 / 0.45); }
|
||||||
|
.raises-cycle .raise { display: none; }
|
||||||
|
.raises-cycle .raise.active { display: block; }
|
||||||
|
.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
|
/* 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. */
|
contender. Graphite instead of kinpaku, and it never takes the lead ring. */
|
||||||
.card.canon .face { border-color: var(--ks-rule); background: var(--ks-graphite); }
|
.card.canon .face { border-color: var(--ks-rule); background: var(--ks-graphite); }
|
||||||
@@ -594,12 +820,47 @@ function page() {
|
|||||||
.card.canon button.choose:hover { border-color: var(--ks-text-muted); background: var(--ks-graphite-2); }
|
.card.canon button.choose:hover { border-color: var(--ks-text-muted); background: var(--ks-graphite-2); }
|
||||||
button.choose { margin-top: auto; align-self: start; background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font-family: var(--ks-font); font-size: 1rem; font-weight: 500; line-height: 1.35; padding: 10px 38px; border-radius: 6px; cursor: pointer; transition: background .15s; }
|
button.choose { margin-top: auto; align-self: start; background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font-family: var(--ks-font); font-size: 1rem; font-weight: 500; line-height: 1.35; padding: 10px 38px; border-radius: 6px; cursor: pointer; transition: background .15s; }
|
||||||
button.choose:hover { background: var(--ks-kinpaku-pale); }
|
button.choose:hover { background: var(--ks-kinpaku-pale); }
|
||||||
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
/* The round's verbs stay reachable on short viewports: the footer is a
|
||||||
|
full-bleed bar stuck to the viewport bottom and the deck scrolls under
|
||||||
|
it. Same inset as the content column, so the controls stay aligned. */
|
||||||
|
footer { position: sticky; bottom: 0; z-index: 10; width: 100vw; margin: 1.2rem calc(50% - 50vw) 0; padding: .7rem var(--page-inset) calc(.7rem + env(safe-area-inset-bottom, 0px)); display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: oklch(7% 0.006 95 / 0.82); backdrop-filter: blur(10px); border-top: 1px solid var(--ks-rule); }
|
||||||
#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 { 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); }
|
#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; }
|
/* Build-path toggle: a workflow preference surfaced as a quiet segmented
|
||||||
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
control on the headline row, right-aligned opposite the title, its trade stated in
|
||||||
#reroll svg { width: 15px; height: 15px; }
|
one line that changes with the selection. The default comes from the
|
||||||
|
payload (settings); flipping binds this session only, and the agent
|
||||||
|
learns about a code-to-comp flip live. Rendered only when the payload
|
||||||
|
offers it, which the agent does only when image generation exists. */
|
||||||
|
#build-path { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; flex: none; margin-left: auto; }
|
||||||
|
.bp-switch { display: inline-flex; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; }
|
||||||
|
.bp-note { text-align: right; }
|
||||||
|
.bp-opt { font-family: var(--ks-mono); font-size: .62rem; letter-spacing: .12em; text-transform: uppercase; padding: 7px 12px; background: transparent; border: 0; color: var(--ks-text-faint); cursor: pointer; transition: color .2s ease, background-color .2s ease; }
|
||||||
|
.bp-opt + .bp-opt { border-left: 1px solid var(--ks-rule); }
|
||||||
|
.bp-opt.active { color: var(--ks-dark-ink); background: var(--ks-kinpaku-rich); }
|
||||||
|
.bp-opt:not(.active):hover { color: var(--ks-text); }
|
||||||
|
.bp-note { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .04em; color: var(--ks-text-faint); max-width: 21rem; line-height: 1.5; }
|
||||||
|
/* Flipping to comp starts billed, minutes-long generation, so it asks
|
||||||
|
first; flipping back is free and never does. */
|
||||||
|
#bp-confirm { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; background: oklch(4% 0.004 95 / 0.72); opacity: 0; transition: opacity .2s ease; }
|
||||||
|
#bp-confirm[hidden] { display: none; }
|
||||||
|
#bp-confirm.open { opacity: 1; }
|
||||||
|
.bp-confirm-panel { max-width: 26rem; margin: 1rem; background: var(--ks-lacquer-raised); border: 1px solid var(--ks-rule); border-radius: 10px; padding: 1.4rem 1.5rem 1.3rem; box-shadow: 0 30px 80px oklch(0% 0 0 / 0.55); }
|
||||||
|
.bp-confirm-panel h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; color: var(--ks-champagne); margin-bottom: .55rem; }
|
||||||
|
.bp-confirm-panel p { font-size: .875rem; line-height: 1.55; color: var(--ks-text-muted); }
|
||||||
|
.bp-confirm-actions { display: flex; gap: .6rem; margin-top: 1.1rem; }
|
||||||
|
.bp-confirm-go { background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font: inherit; font-weight: 500; padding: 9px 22px; border-radius: 6px; cursor: pointer; }
|
||||||
|
.bp-confirm-go:hover { background: var(--ks-kinpaku-pale); }
|
||||||
|
.bp-confirm-stay { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font: inherit; padding: 9px 18px; border-radius: 6px; cursor: pointer; }
|
||||||
|
.bp-confirm-stay:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
|
||||||
|
.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
|
/* 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,
|
to the dealt cards and the re-roll so it reads as the user's own door,
|
||||||
not a recommendation. */
|
not a recommendation. */
|
||||||
@@ -620,6 +881,16 @@ function page() {
|
|||||||
<div id="ambient" aria-hidden="true"></div>
|
<div id="ambient" aria-hidden="true"></div>
|
||||||
<div id="scrim" aria-hidden="true"></div>
|
<div id="scrim" aria-hidden="true"></div>
|
||||||
<div id="lightbox" hidden><img alt=""></div>
|
<div id="lightbox" hidden><img alt=""></div>
|
||||||
|
${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria-labelledby="bp-confirm-title" hidden>
|
||||||
|
<div class="bp-confirm-panel">
|
||||||
|
<h2 id="bp-confirm-title">Flip to comp-first?</h2>
|
||||||
|
<p>The agent starts rendering a comp for every open card right away, about a minute or two per card on your image provider, and the images land on the cards as they finish. This flip binds this session only.</p>
|
||||||
|
<div class="bp-confirm-actions">
|
||||||
|
<button type="button" class="bp-confirm-go" data-confirm>Render comps</button>
|
||||||
|
<button type="button" class="bp-confirm-stay" data-cancel>Keep code-first</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
<header>
|
<header>
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" 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>
|
<svg viewBox="0 0 24 24" fill="currentColor" 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>
|
||||||
@@ -631,6 +902,13 @@ function page() {
|
|||||||
<div class="headline">
|
<div class="headline">
|
||||||
<svg class="headline-die" 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>
|
<svg class="headline-die" 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>
|
||||||
<h1>${esc(payload.title || 'Choose a direction')}</h1>
|
<h1>${esc(payload.title || 'Choose a direction')}</h1>
|
||||||
|
${buildPath?.toggle ? `<div id="build-path" data-default="${buildPath.value}">
|
||||||
|
<div class="bp-switch" role="radiogroup" aria-label="Build path">
|
||||||
|
<button type="button" class="bp-opt" data-bp="comp" role="radio" aria-checked="false">Comp first</button>
|
||||||
|
<button type="button" class="bp-opt" data-bp="code" role="radio" aria-checked="false">Code first</button>
|
||||||
|
</div>
|
||||||
|
<p class="bp-note" data-bp-note></p>
|
||||||
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${payload.question ? `<p class="question">${esc(payload.question)}</p>` : ''}
|
${payload.question ? `<p class="question">${esc(payload.question)}</p>` : ''}
|
||||||
<div class="deck-shell">
|
<div class="deck-shell">
|
||||||
@@ -644,16 +922,33 @@ function page() {
|
|||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
|
${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>' : ''}
|
${payload.canon && !payload.canonCard ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
|
||||||
</footer>
|
</footer>
|
||||||
<script>
|
<script>
|
||||||
const steer = () => document.getElementById('steer')?.value || '';
|
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' }); } };
|
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
|
||||||
beat();
|
beat();
|
||||||
setInterval(beat, 5000);
|
setInterval(beat, 5000);
|
||||||
async function answer(optionId) {
|
async function answer(optionId) {
|
||||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
|
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.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)));
|
document.querySelectorAll('button.choose').forEach(b => b.addEventListener('click', () => answer(b.dataset.id)));
|
||||||
@@ -662,6 +957,25 @@ function page() {
|
|||||||
b.closest('.card').classList.toggle('flipped');
|
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 = 'Improvement ' + (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,
|
// Deal from the stack: cards begin piled at the grid's center, blurred,
|
||||||
// then travel to their seats with a stagger.
|
// then travel to their seats with a stagger.
|
||||||
const cards = [...document.querySelectorAll('.card')];
|
const cards = [...document.querySelectorAll('.card')];
|
||||||
@@ -695,46 +1009,147 @@ function page() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sketches stream in after the deal: poll each slot until the file lands,
|
// Comps stream in after the deal: poll each slot until the file lands,
|
||||||
// then swap the shimmer for the image. Generation is genuinely slow and a
|
// then swap the shimmer for the image. Generation is genuinely slow and a
|
||||||
// sequential batch puts the last card many minutes out, so patience is the
|
// sequential batch puts the last card many minutes out, so patience is the
|
||||||
// default: a slot only shows its inspiration as a stand-in when it has
|
// default: a slot only shows its inspiration as a stand-in when it has
|
||||||
// waited four minutes AND nothing has landed anywhere for four minutes, the
|
// waited four minutes AND nothing has landed anywhere for four minutes, the
|
||||||
// stand-in is labeled as such, and polling continues so the real sketch
|
// stand-in is labeled as such, and polling continues so the real comp
|
||||||
// still swaps in whenever it arrives. Progress anywhere resets patience.
|
// still swaps in whenever it arrives. Progress anywhere resets patience.
|
||||||
const landTracker = { last: Date.now() };
|
const landTracker = { last: Date.now() };
|
||||||
document.querySelectorAll('.media.sketching').forEach(m => {
|
const pollComp = (m) => {
|
||||||
const url = m.dataset.sketch;
|
const url = m.dataset.comp;
|
||||||
const img = m.querySelector('img.sketch');
|
const img = m.querySelector('img.comp');
|
||||||
const note = m.querySelector('.sketch-note');
|
const note = m.querySelector('.comp-note');
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
// A live elapsed count is the difference between "working" and "frozen".
|
// 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 settle = () => { clearInterval(tick); m.classList.remove('comp-pending', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
|
||||||
const standIn = () => {
|
const fallback = () => {
|
||||||
const pip = m.querySelector('.pip img');
|
const pip = m.querySelector('.pip img');
|
||||||
if (!pip || m.classList.contains('stand-in')) return;
|
if (pip) {
|
||||||
img.src = pip.getAttribute('src'); img.hidden = false;
|
if (m.classList.contains('stand-in')) return false;
|
||||||
m.classList.add('stand-in');
|
img.src = pip.getAttribute('src'); img.hidden = false;
|
||||||
m.querySelector('.shimmer')?.remove();
|
m.classList.add('stand-in');
|
||||||
clearInterval(tick);
|
m.querySelector('.shimmer')?.remove();
|
||||||
const label = document.createElement('p');
|
clearInterval(tick);
|
||||||
label.className = 'stand-in-label';
|
const label = document.createElement('p');
|
||||||
label.textContent = 'inspiration · sketch pending';
|
label.className = 'stand-in-label';
|
||||||
m.appendChild(label);
|
label.textContent = 'inspiration · comp pending';
|
||||||
|
m.appendChild(label);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No comp and no inspiration is the text-only card the payload would
|
||||||
|
// have rendered without a comp declaration. Bring the complete read
|
||||||
|
// forward before removing the now-unreachable back face.
|
||||||
|
const card = m.closest('.card');
|
||||||
|
const front = card?.querySelector('.face.front');
|
||||||
|
const body = front?.querySelector('.body');
|
||||||
|
const back = card?.querySelector('.face.back');
|
||||||
|
const textOnlyFacts = m.querySelector('template.text-only-facts');
|
||||||
|
const choose = body?.querySelector(':scope > button.choose');
|
||||||
|
if (body && textOnlyFacts && choose) {
|
||||||
|
const plainDetail = body.querySelector(':scope > .detail:not(.more)');
|
||||||
|
[...body.children].filter((el) => el.classList.contains('fact') || el.matches('.detail.more')).forEach((el) => el.remove());
|
||||||
|
choose.before(textOnlyFacts.content.cloneNode(true));
|
||||||
|
if (plainDetail) choose.before(plainDetail);
|
||||||
|
}
|
||||||
|
card?.classList.remove('flipped');
|
||||||
|
front?.classList.add('text-only');
|
||||||
|
back?.remove();
|
||||||
|
settle();
|
||||||
|
m.remove();
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
const tryLoad = () => {
|
const tryLoad = () => {
|
||||||
|
// A slot the user flipped back out of leaves the DOM; let its loop die.
|
||||||
|
if (!m.isConnected) { clearInterval(tick); return; }
|
||||||
const probe = new Image();
|
const probe = new Image();
|
||||||
probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); };
|
probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); };
|
||||||
probe.onerror = () => {
|
probe.onerror = () => {
|
||||||
const quiet = Date.now() - landTracker.last > 240000;
|
const quiet = Date.now() - landTracker.last > 240000;
|
||||||
if (Date.now() - started > 240000 && quiet) standIn();
|
if (Date.now() - started > 240000 && quiet && fallback()) return;
|
||||||
setTimeout(tryLoad, m.classList.contains('stand-in') ? 5000 : 2500);
|
setTimeout(tryLoad, m.classList.contains('stand-in') ? 5000 : 2500);
|
||||||
};
|
};
|
||||||
probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||||
};
|
};
|
||||||
tryLoad();
|
tryLoad();
|
||||||
});
|
};
|
||||||
|
document.querySelectorAll('.media.comp-pending').forEach(pollComp);
|
||||||
|
|
||||||
|
// Build-path toggle: the default is the round's recorded preference and
|
||||||
|
// flipping binds this session only. Flipping code to comp swaps every
|
||||||
|
// reserve slot (data-comp-slot) to its shimmer and tells the server, so
|
||||||
|
// the waiting agent starts generating; flipping back is free: pending
|
||||||
|
// slots return to their wireframes, a comp that already landed stays.
|
||||||
|
const bp = document.getElementById('build-path');
|
||||||
|
if (bp) {
|
||||||
|
const notes = {
|
||||||
|
comp: 'An image sets the bar first and the build must match it. Bolder composition; comps render before code.',
|
||||||
|
code: 'Code builds directly; the ambition is written into the contract and audited at the finish. Leaner, faster.',
|
||||||
|
};
|
||||||
|
const noteEl = bp.querySelector('[data-bp-note]');
|
||||||
|
let current = bp.dataset.default;
|
||||||
|
const set = (value) => {
|
||||||
|
current = value;
|
||||||
|
bp.querySelectorAll('.bp-opt').forEach(b => {
|
||||||
|
const on = b.dataset.bp === value;
|
||||||
|
b.classList.toggle('active', on);
|
||||||
|
b.setAttribute('aria-checked', String(on));
|
||||||
|
});
|
||||||
|
if (noteEl) noteEl.textContent = notes[value];
|
||||||
|
};
|
||||||
|
set(current);
|
||||||
|
const enterComp = () => {
|
||||||
|
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
|
||||||
|
const front = card.querySelector('.face.front');
|
||||||
|
if (!front || front.querySelector('.media.comp-pending') || front.querySelector('.media img.comp:not([hidden])')) return;
|
||||||
|
const m = document.createElement('div');
|
||||||
|
m.className = 'media comp-pending';
|
||||||
|
m.dataset.comp = card.dataset.compSlot;
|
||||||
|
m.innerHTML = '<div class="shimmer"><span class="comp-note">rendering…</span></div><img class="comp" alt="" hidden>';
|
||||||
|
const wireEl = front.querySelector('.media.wire');
|
||||||
|
if (wireEl) { wireEl.hidden = true; front.insertBefore(m, wireEl); }
|
||||||
|
else { front.classList.remove('text-only'); front.insertBefore(m, front.querySelector('.body')); }
|
||||||
|
pollComp(m);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const exitComp = () => {
|
||||||
|
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
|
||||||
|
const front = card.querySelector('.face.front');
|
||||||
|
const pending = front?.querySelector('.media.comp-pending');
|
||||||
|
if (!pending) return; // landed comps stay; they exist either way
|
||||||
|
pending.remove();
|
||||||
|
const wireEl = front.querySelector('.media.wire');
|
||||||
|
if (wireEl) wireEl.hidden = false;
|
||||||
|
else if (!front.querySelector('.media')) front.classList.add('text-only');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const apply = (value) => {
|
||||||
|
set(value);
|
||||||
|
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
|
||||||
|
if (value === 'comp') enterComp(); else exitComp();
|
||||||
|
};
|
||||||
|
// Flipping to comp starts real generation, so it confirms first; the
|
||||||
|
// flip back is free and applies immediately.
|
||||||
|
const confirm = document.getElementById('bp-confirm');
|
||||||
|
const closeConfirm = () => { confirm.classList.remove('open'); confirm.hidden = true; };
|
||||||
|
confirm.querySelector('[data-confirm]').addEventListener('click', () => { closeConfirm(); apply('comp'); });
|
||||||
|
confirm.querySelector('[data-cancel]').addEventListener('click', closeConfirm);
|
||||||
|
confirm.addEventListener('click', (e) => { if (e.target === confirm) closeConfirm(); });
|
||||||
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !confirm.hidden) closeConfirm(); });
|
||||||
|
bp.querySelectorAll('.bp-opt').forEach(b => b.addEventListener('click', () => {
|
||||||
|
const value = b.dataset.bp;
|
||||||
|
if (value === current) return;
|
||||||
|
if (value === 'comp') {
|
||||||
|
confirm.hidden = false;
|
||||||
|
requestAnimationFrame(() => confirm.classList.add('open'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apply(value);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// A declared image that never loads (missing catalog asset, offline shell)
|
// 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
|
// must not sit as a dark void: the slot collapses to the card's own
|
||||||
@@ -742,7 +1157,7 @@ function page() {
|
|||||||
// slots are excluded; their polling owns the wait.
|
// slots are excluded; their polling owns the wait.
|
||||||
const artFailed = (img) => {
|
const artFailed = (img) => {
|
||||||
const m = img.closest('.media');
|
const m = img.closest('.media');
|
||||||
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
|
if (!m || m.classList.contains('comp-pending') || m.classList.contains('unavailable')) return;
|
||||||
m.classList.add('unavailable');
|
m.classList.add('unavailable');
|
||||||
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
|
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(', ') + ')';
|
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(', ') + ')';
|
||||||
@@ -755,19 +1170,19 @@ function page() {
|
|||||||
label.textContent = 'artwork unavailable';
|
label.textContent = 'artwork unavailable';
|
||||||
m.appendChild(label);
|
m.appendChild(label);
|
||||||
};
|
};
|
||||||
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
|
document.querySelectorAll('.media:not(.comp-pending) > img').forEach(img => {
|
||||||
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
||||||
else img.addEventListener('error', () => artFailed(img), { once: true });
|
else img.addEventListener('error', () => artFailed(img), { once: true });
|
||||||
});
|
});
|
||||||
// A broken inspiration PIP just leaves; nothing depends on it.
|
// A broken inspiration PIP or thumb just leaves; nothing depends on it.
|
||||||
document.querySelectorAll('.pip img').forEach(img => {
|
document.querySelectorAll('.pip img, .inspo img').forEach(img => {
|
||||||
const gone = () => img.closest('.pip')?.remove();
|
const gone = () => img.closest('.pip, .inspo')?.remove();
|
||||||
if (img.complete && img.naturalWidth === 0) gone();
|
if (img.complete && img.naturalWidth === 0) gone();
|
||||||
else img.addEventListener('error', gone, { once: true });
|
else img.addEventListener('error', gone, { once: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Inspiration PIP opens the full catalog card in the lightbox.
|
// Inspiration PIP or body thumb opens the full catalog card in the lightbox.
|
||||||
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
|
document.querySelectorAll('.pip, .inspo').forEach(p => p.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const img = p.querySelector('img');
|
const img = p.querySelector('img');
|
||||||
if (!img) return;
|
if (!img) return;
|
||||||
@@ -814,7 +1229,7 @@ function page() {
|
|||||||
const ambient = document.getElementById('ambient');
|
const ambient = document.getElementById('ambient');
|
||||||
document.querySelectorAll('.card').forEach(card => {
|
document.querySelectorAll('.card').forEach(card => {
|
||||||
card.addEventListener('mouseenter', () => {
|
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;
|
if (!art || !art.getAttribute('src')) return;
|
||||||
ambient.style.backgroundImage = 'url("' + art.getAttribute('src') + '")'; ambient.style.opacity = '1';
|
ambient.style.backgroundImage = 'url("' + art.getAttribute('src') + '")'; ambient.style.opacity = '1';
|
||||||
});
|
});
|
||||||
@@ -862,8 +1277,11 @@ function page() {
|
|||||||
lightbox.addEventListener('click', closeLightbox);
|
lightbox.addEventListener('click', closeLightbox);
|
||||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
||||||
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
|
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
|
||||||
document.getElementById('reroll')?.addEventListener('click', async () => {
|
const dealAgain = async (register) => {
|
||||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
|
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 grid = document.querySelector('.grid');
|
||||||
const cardsNow = [...grid.querySelectorAll('.card')];
|
const cardsNow = [...grid.querySelectorAll('.card')];
|
||||||
const g = grid.getBoundingClientRect();
|
const g = grid.getBoundingClientRect();
|
||||||
@@ -880,14 +1298,17 @@ function page() {
|
|||||||
}
|
}
|
||||||
const cardHeight = cardsNow[0] ? cardsNow[0].getBoundingClientRect().height : 0;
|
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('');
|
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 () => {
|
const poll = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const status = await (await fetch('/next-status')).json();
|
const status = await (await fetch('/next-status')).json();
|
||||||
if (status.ready) { clearInterval(poll); location.reload(); }
|
if (status.ready) { clearInterval(poll); location.reload(); }
|
||||||
} catch { /* server briefly busy */ }
|
} catch { /* server briefly busy */ }
|
||||||
}, 1200);
|
}, 1200);
|
||||||
});
|
}
|
||||||
|
document.getElementById('reroll')?.addEventListener('click', () => dealAgain());
|
||||||
|
document.getElementById('reroll-safer')?.addEventListener('click', () => dealAgain('safer'));
|
||||||
|
document.getElementById('reroll-bolder')?.addEventListener('click', () => dealAgain('bolder'));
|
||||||
</script>`;
|
</script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -935,6 +1356,26 @@ const server = http.createServer((req, res) => {
|
|||||||
fs.createReadStream(abs).pipe(res);
|
fs.createReadStream(abs).pipe(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/build-path') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk) => { body += chunk; });
|
||||||
|
req.on('end', () => {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end('{"ok":true}');
|
||||||
|
let value = null;
|
||||||
|
try { value = JSON.parse(body).value; } catch { /* ignore */ }
|
||||||
|
if (value !== 'comp' && value !== 'code') return;
|
||||||
|
const wasComp = liveBuildPath === 'comp';
|
||||||
|
liveBuildPath = value;
|
||||||
|
// Only a flip TO comp needs the agent mid-round: comps must start
|
||||||
|
// rendering into the declared slots. The reverse is free.
|
||||||
|
if (detachedKey && value === 'comp' && !wasComp) {
|
||||||
|
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (req.method === 'POST' && req.url === '/answer') {
|
if (req.method === 'POST' && req.url === '/answer') {
|
||||||
let body = '';
|
let body = '';
|
||||||
req.on('data', (chunk) => { body += chunk; });
|
req.on('data', (chunk) => { body += chunk; });
|
||||||
@@ -944,22 +1385,30 @@ const server = http.createServer((req, res) => {
|
|||||||
let parsed = {};
|
let parsed = {};
|
||||||
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
|
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
|
||||||
const chosen = options.find((o) => o.id === parsed.optionId);
|
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({
|
const answer = JSON.stringify({
|
||||||
optionId: parsed.optionId ?? null,
|
optionId: parsed.optionId ?? null,
|
||||||
steer: parsed.steer ?? '',
|
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?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||||
...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
|
...((chosen?.comp ?? chosen?.sketch) ? { comp: chosen.comp ?? chosen.sketch } : {}),
|
||||||
|
...(liveBuildPath && !isReroll ? { buildPath: liveBuildPath, buildPathFlipped: liveBuildPath !== (buildPathDefault?.value ?? null) } : {}),
|
||||||
});
|
});
|
||||||
const isReroll = parsed.optionId === 'reroll';
|
|
||||||
if (detachedKey) {
|
if (detachedKey) {
|
||||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||||
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
|
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
|
||||||
} else {
|
} else {
|
||||||
printAnswer(answer);
|
printAnswer(answer);
|
||||||
}
|
}
|
||||||
// A re-roll in detached mode keeps the table open: the client shows a
|
// A re-roll or followup pick in detached mode keeps the table open: the
|
||||||
// loading hand and reloads when --update delivers the next round.
|
// client shows a loading hand and reloads when --update delivers the
|
||||||
if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150);
|
// next round.
|
||||||
|
if (!((isReroll || followupOpen) && detachedKey)) setTimeout(() => process.exit(0), 150);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
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 `comp` 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 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. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||||
|
|
||||||
## Input Contract
|
## Input Contract
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
|
|||||||
|
|
||||||
## Input Contract
|
## 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
|
## 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.
|
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. 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.
|
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.
|
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.
|
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.
|
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
|
## 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.
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ This skill gives you the tools and permission to create design that earns to be
|
|||||||
Core principles:
|
Core principles:
|
||||||
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
|
- 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.
|
- 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
|
## 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.
|
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.
|
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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user