mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
Compare commits
103
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e8eea2d91 | ||
|
|
aa1b4e4b02 | ||
|
|
6e6b0227b0 | ||
|
|
a075d89bdb | ||
|
|
e76b3424d2 | ||
|
|
e46e0da885 | ||
|
|
b14df98183 | ||
|
|
6886ab8c0e | ||
|
|
ae5e95101a | ||
|
|
a37b3f6b02 | ||
|
|
d086837dfc | ||
|
|
80e4dd0d58 | ||
|
|
2f609915eb | ||
|
|
ebaf9f1d5b | ||
|
|
d417ff1f01 | ||
|
|
e15d8e122f | ||
|
|
3b35161000 | ||
|
|
ca7981f669 | ||
|
|
620ba1fe7d | ||
|
|
1045c6ca98 | ||
|
|
d28dbc7a8d | ||
|
|
667095d216 | ||
|
|
14d2641685 | ||
|
|
e2761cae80 | ||
|
|
85f84bf620 | ||
|
|
1a3f588c71 | ||
|
|
731cd2e6cd | ||
|
|
b33feacbe9 | ||
|
|
df09de3676 | ||
|
|
de7b72843f | ||
|
|
d6a9891066 | ||
|
|
6d2af3f800 | ||
|
|
69b63d36b6 | ||
|
|
baa76cfd05 | ||
|
|
71ccba9f5b | ||
|
|
d69bd093f9 | ||
|
|
5dfeba6d3e | ||
|
|
2345868c7b | ||
|
|
57ce11288f | ||
|
|
707794c597 | ||
|
|
ae118ebf57 | ||
|
|
3125864d1a | ||
|
|
dd0279b6bd | ||
|
|
b32a02d02f | ||
|
|
9529f07840 | ||
|
|
ad30c67dca | ||
|
|
7d6109b723 | ||
|
|
33b9a3752b | ||
|
|
bc51310dad | ||
|
|
0c9ce16248 | ||
|
|
ae2be34fdc | ||
|
|
8cf362b6fe | ||
|
|
bcfb7efc46 | ||
|
|
62a2026afc | ||
|
|
c90faaab55 | ||
|
|
febce52e8d | ||
|
|
c5e1ddd054 | ||
|
|
ae03e9e09c | ||
|
|
60c860f022 | ||
|
|
675656c18c | ||
|
|
0f80c1f5aa | ||
|
|
f2f73edb33 | ||
|
|
b1c5707fde | ||
|
|
af56fae571 | ||
|
|
1052f6c3a4 | ||
|
|
4f10aed4ba | ||
|
|
b89b4c41d3 | ||
|
|
1d4b98ea01 | ||
|
|
68f13a225e | ||
|
|
65a5197439 | ||
|
|
3a2d3a9c42 | ||
|
|
c91f3717d4 | ||
|
|
a3d7b247aa | ||
|
|
c91047d66a | ||
|
|
c5eb38a381 | ||
|
|
c83a7eced2 | ||
|
|
33f824b5b5 | ||
|
|
463ba38860 | ||
|
|
358fc2e716 | ||
|
|
1b4a0b9bac | ||
|
|
2d489f898b | ||
|
|
1efdff9aea | ||
|
|
0a3c12f78e | ||
|
|
bf957452c4 | ||
|
|
32930818a1 | ||
|
|
08c2323b51 | ||
|
|
dc5d9baa0e | ||
|
|
7b202c3223 | ||
|
|
24a014ddcf | ||
|
|
166e4481e1 | ||
|
|
f72fcad7d6 | ||
|
|
827dfeb95e | ||
|
|
5b2df20b85 | ||
|
|
19b5fa40d0 | ||
|
|
42bc53eac9 | ||
|
|
38d2c393dc | ||
|
|
f274ca2c01 | ||
|
|
de9d543825 | ||
|
|
7a0489bd91 | ||
|
|
a209eeb0bd | ||
|
|
6b342244e9 | ||
|
|
adc798debb | ||
|
|
9d1b4bdfac |
@@ -18,7 +18,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
|
|||||||
## 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
|
||||||
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
|
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.
|
||||||
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.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
|
|||||||
- **Type:** body measure 65–75ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
|
- **Type:** body measure 65–75ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
|
||||||
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
|
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
|
||||||
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
|
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
|
||||||
|
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
|
||||||
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
|
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
|
||||||
- **Coverage:** every brief requirement present and findable within seconds.
|
- **Coverage:** every brief requirement present and findable within seconds.
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
|
|||||||
## 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
|
||||||
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
|
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.
|
||||||
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.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ The script assigns which structure gets built; your top-ranked structure is what
|
|||||||
|
|
||||||
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, 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.
|
||||||
|
|
||||||
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, the canon card included. Serve the page first, then produce the sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
|
When image generation exists, every card also declares a `sketch` path under `.impeccable/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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -84,9 +84,12 @@ For `shape`, return the selected direction to [shape.md](shape.md) and stop befo
|
|||||||
|
|
||||||
## 6. Build with full commitment
|
## 6. Build with full commitment
|
||||||
|
|
||||||
|
When an approved comp exists, the comp is king, and the build happens in phases. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses.
|
||||||
|
|
||||||
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
|
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
|
||||||
|
|
||||||
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
|
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
|
||||||
|
- **Prove the hero before building past it.** When an approved comp exists, render the first viewport, capture it, and set it beside the comp's first viewport before any later section: the hero carries the run's ambition, and every following section inherits its shortfall. Judge scale and density as quantities, a field at a tenth of the comp's coverage or type at half its weight is a different design, and a five-minute retry here is what a rebuild verdict at the finish costs when this check is skipped.
|
||||||
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
|
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
|
||||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
|
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
|
||||||
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
|
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
|
||||||
@@ -100,6 +103,6 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
|||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. Where this harness runs no design hook, run `node .agents/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch: put that verdict in front of the user with the named comp regions and let them choose between a re-derivation and shipping as it stands. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
|
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. 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.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ After approval, record the choice where tools can find it: the approved comp's p
|
|||||||
|
|
||||||
## Inventory implementation fidelity
|
## Inventory implementation fidelity
|
||||||
|
|
||||||
Before building, inventory the approved comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same written inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
|
Before building, read the approved comp as a design system and record it in the brief: component grammar, corner language, line weights, elevation treatment, and the type ramp, because everything the comp does not show gets built from this record, and without it the fallback is the model's stock kit of square boxes, 1px grids, bento cells, and hard shadows. Then inventory the comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same written inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. The primary action gets its own row with its own medium: when the comp dissolves, stamps, erodes, or otherwise physically works the main CTA, that treatment is signature material on the page's most important element, and shrinking it to a border trick or a few decorative pixels is the compliance-token version of commitment. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
|
||||||
|
|
||||||
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack; writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish, is not a medium choice, it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly, diagrams with countable elements, controls, flat shape systems, and it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region instead of producing it is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity, so "no photography on hand" forbids fake proof, not an illustrated hero.
|
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack, and so is any texture by that name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient or layered background is not a texture medium and "layered CSS textures" is not a medium at all. Writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish or a cotton field's weave, is not a medium choice, it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly, diagrams with countable elements, controls, flat shape systems, and it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region instead of producing it is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity, so "no photography on hand" forbids fake proof, not an illustrated hero.
|
||||||
|
|
||||||
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond, and code executed safely and professionally remains first-class there. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
|
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond, and code executed safely and professionally remains first-class there. A field or texture built from many small elements carries a quantity commitment either way: write down its approximate density and coverage ("thousands of glyphs over two-thirds of the fold, dense at the top fading into the path"), because a field rebuilt at a tenth of its density passes every checklist and still is not the design. TYPE rows carry the same discipline: name the face's compression class, and render one headline word against the comp before building on it; a visibly wider or lighter silhouette means the face is wrong, and every section built on it inherits the miss. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
|
||||||
|
|
||||||
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies.
|
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies.
|
||||||
|
|
||||||
@@ -42,6 +42,8 @@ Treat the comp as a north star, not something to trace, and know what that allow
|
|||||||
|
|
||||||
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .agents/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
|
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .agents/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
|
||||||
|
|
||||||
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
|
When the harness runs subagents, spawn the shipped asset producer every time, even when the inventory's produce bucket looks empty: its manifest is the independent second opinion on your media, and runs that skipped the spawn are the runs whose cotton became CSS. An honestly empty manifest costs one cheap spawn; a wrongly empty produce bucket costs the build its materials. Use the producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
|
||||||
|
|
||||||
|
Convert images with a converter context.mjs reported at boot (the IMAGE_TOOLS line); probe only when it reported none, at most once per session, never per image.
|
||||||
|
|
||||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||||
|
|||||||
@@ -405,13 +405,18 @@ ASSIGNED INDEX: ${buildIndex}
|
|||||||
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.
|
||||||
No challengers this run: the roll service was unreachable and no local
|
No challengers this run: the roll service was unreachable and no local
|
||||||
catalog exists. A sandboxed exec tool with no network access causes exactly
|
catalog exists. A sandboxed shell without network egress is the most common
|
||||||
this; before accepting degradation, rerun this command once through the
|
cause; before accepting degradation, rerun this command once through the
|
||||||
harness's network-enabled command tool. A sandboxed shell without network egress is the most common
|
harness's network-enabled or escalated command tool. When that rerun needs
|
||||||
cause: if this harness can rerun the command with network access granted,
|
an approval, state exactly what the approver must know: this script's only
|
||||||
do that once before proceeding. Otherwise proceed with the grounded
|
network contact is one GET to https://impeccable.style/api/roll whose query
|
||||||
candidates alone; the assignment
|
carries scope, mode, an eight-hex seed key, and a re-roll counter; no
|
||||||
above still applies at full strength. Tell the user plainly that this roll
|
project files, prompts, code, or conversation context are transmitted, and
|
||||||
|
nothing is written. An approval request naming that URL and payload judges
|
||||||
|
the real action; a bare "run with network" invites rejection for contacting
|
||||||
|
an unspecified domain. If the rerun is still refused, proceed with the
|
||||||
|
grounded candidates alone; the assignment above still applies at full
|
||||||
|
strength. Tell the user plainly that this roll
|
||||||
ran degraded, with no challengers and no quality-bar boards; do not present
|
ran degraded, with no challengers and no quality-bar boards; do not present
|
||||||
the outcome as a full roll. A degraded roll changes the cards, not the
|
the outcome as a full roll. A degraded roll changes the cards, not the
|
||||||
channel: when a browser can open, present the direction on the decision page
|
channel: when a browser can open, present the direction on the decision page
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { loadContext, extractPlatform } from './context.mjs';
|
import { loadContext, extractPlatform } from './context.mjs';
|
||||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
import { readLatestSnapshotAcrossTargets } from './critique-storage.mjs';
|
||||||
|
|
||||||
/** Is there code here at all, or just context files / an empty repo? */
|
/** Is there code here at all, or just context files / an empty repo? */
|
||||||
function hasCode(cwd) {
|
function hasCode(cwd) {
|
||||||
@@ -34,23 +34,13 @@ function hasCode(cwd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The most recent critique snapshot across all targets. Filenames are
|
* Summarize the most recent critique snapshot across all targets.
|
||||||
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
|
|
||||||
* Parses the small frontmatter for score + P0/P1 counts.
|
|
||||||
*/
|
*/
|
||||||
function latestCritique(cwd) {
|
function latestCritique(cwd) {
|
||||||
try {
|
try {
|
||||||
const dir = getCritiqueDir(cwd);
|
const latest = readLatestSnapshotAcrossTargets({ cwd });
|
||||||
if (!fs.existsSync(dir)) return null;
|
if (!latest) return null;
|
||||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
|
const get = (key) => latest.meta[key] ?? null;
|
||||||
if (!files.length) return null;
|
|
||||||
const newest = files[files.length - 1];
|
|
||||||
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
|
|
||||||
const front = text.split('---')[1] || '';
|
|
||||||
const get = (k) => {
|
|
||||||
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
|
|
||||||
return m ? m[1].trim() : null;
|
|
||||||
};
|
|
||||||
const num = (v) => {
|
const num = (v) => {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
@@ -61,7 +51,7 @@ function latestCritique(cwd) {
|
|||||||
p0: num(get('p0')),
|
p0: num(get('p0')),
|
||||||
p1: num(get('p1')),
|
p1: num(get('p1')),
|
||||||
timestamp: get('timestamp'),
|
timestamp: get('timestamp'),
|
||||||
file: path.relative(cwd, path.join(dir, newest)),
|
file: path.relative(cwd, latest.path),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
* shape rather than the markdown block.
|
* shape rather than the markdown block.
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -1146,6 +1147,7 @@ async function cli() {
|
|||||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||||
parts.push(buildMissingTargetDirective());
|
parts.push(buildMissingTargetDirective());
|
||||||
}
|
}
|
||||||
|
appendImageToolsDirective(parts);
|
||||||
appendStalenessDirective(parts, ctx, cliOptions);
|
appendStalenessDirective(parts, ctx, cliOptions);
|
||||||
if (updateDirective) parts.push(updateDirective);
|
if (updateDirective) parts.push(updateDirective);
|
||||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||||
@@ -1180,6 +1182,7 @@ async function cli() {
|
|||||||
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
|
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
appendImageToolsDirective(parts);
|
||||||
appendStalenessDirective(parts, ctx, cliOptions);
|
appendStalenessDirective(parts, ctx, cliOptions);
|
||||||
if (!ctx.platform) {
|
if (!ctx.platform) {
|
||||||
// A `## Platform` section that names something we don't recognize (a
|
// A `## Platform` section that names something we don't recognize (a
|
||||||
@@ -1275,9 +1278,10 @@ function appendImageGenDirective(parts) {
|
|||||||
if (!process.env.OPENAI_API_KEY) return;
|
if (!process.env.OPENAI_API_KEY) return;
|
||||||
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
|
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
|
||||||
parts.push([
|
parts.push([
|
||||||
'IMAGE_GEN_AVAILABLE: An OpenAI key is present, so image generation works even without a harness-native image tool:',
|
'IMAGE_GEN_AVAILABLE: your harness-native image tool is always the first choice for generation; use it whenever one exists.',
|
||||||
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render).`,
|
'This environment also carries an OpenAI key as the fallback for harnesses with no native tool:',
|
||||||
'Prefer the harness-native image tool when one exists. Visualizing a direction before building it measurably strengthens the result.',
|
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render, and never reach for it when a native tool exists).`,
|
||||||
|
'Visualizing a direction before building it measurably strengthens the result.',
|
||||||
].join(' '));
|
].join(' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1332,6 +1336,19 @@ function appendDetectorFallback(parts, ctx) {
|
|||||||
// markdown already in memory, a bounded set of stats, or one of the small JSON
|
// markdown already in memory, a bounded set of stats, or one of the small JSON
|
||||||
// files the boot reads regardless. The deep pass (git drift, token divergence,
|
// files the boot reads regardless. The deep pass (git drift, token divergence,
|
||||||
// cross-workspace sweep) belongs to the doctor command, not to every session.
|
// cross-workspace sweep) belongs to the doctor command, not to every session.
|
||||||
|
// One boot-time probe replaces every session re-deriving its image toolchain:
|
||||||
|
// harnesses and OSes differ (cwebp, sips on macOS, magick, ffmpeg), and the
|
||||||
|
// agent should read this line instead of running command -v per image.
|
||||||
|
function appendImageToolsDirective(parts) {
|
||||||
|
const probe = process.platform === 'win32' ? 'where' : 'which';
|
||||||
|
const found = ['cwebp', 'sips', 'magick', 'ffmpeg'].filter((tool) => {
|
||||||
|
try { return spawnSync(probe, [tool], { stdio: 'ignore' }).status === 0; } catch { return false; }
|
||||||
|
});
|
||||||
|
parts.push(found.length
|
||||||
|
? `IMAGE_TOOLS: available image converters on this machine: ${found.join(', ')}. Use the first suitable one; never probe again this session.`
|
||||||
|
: 'IMAGE_TOOLS: no image converter found (cwebp, sips, magick, ffmpeg). Ship PNG output unconverted rather than probing per image.');
|
||||||
|
}
|
||||||
|
|
||||||
function appendStalenessDirective(parts, ctx, options) {
|
function appendStalenessDirective(parts, ctx, options) {
|
||||||
const projectRoot = ctx.projectRoot || process.cwd();
|
const projectRoot = ctx.projectRoot || process.cwd();
|
||||||
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
|
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
|
||||||
|
|||||||
@@ -105,28 +105,37 @@ function parseFrontmatter(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return all snapshot files for `slug`, sorted oldest → newest.
|
* Return snapshot files matching `suffix`, sorted oldest → newest.
|
||||||
*/
|
*/
|
||||||
function listSnapshotsForSlug(slug, cwd) {
|
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
|
||||||
|
|
||||||
|
function listSnapshots(suffix, cwd) {
|
||||||
const dir = getCritiqueDir(cwd);
|
const dir = getCritiqueDir(cwd);
|
||||||
if (!fs.existsSync(dir)) return [];
|
if (!fs.existsSync(dir)) return [];
|
||||||
const suffix = `__${slug}.md`;
|
|
||||||
return fs.readdirSync(dir)
|
return fs.readdirSync(dir)
|
||||||
.filter((f) => f.endsWith(suffix))
|
.filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
|
||||||
.sort()
|
.sort()
|
||||||
.map((f) => path.join(dir, f));
|
.map((f) => path.join(dir, f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readLatestSnapshotMatching(suffix, cwd) {
|
||||||
|
const filePath = listSnapshots(suffix, cwd).at(-1);
|
||||||
|
if (!filePath) return null;
|
||||||
|
const body = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return { path: filePath, body, meta: parseFrontmatter(body) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
||||||
* to find its fix backlog when the slug matches.
|
* to find its fix backlog when the slug matches.
|
||||||
*/
|
*/
|
||||||
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||||
const all = listSnapshotsForSlug(slug, cwd);
|
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
|
||||||
if (!all.length) return null;
|
}
|
||||||
const latest = all[all.length - 1];
|
|
||||||
const body = fs.readFileSync(latest, 'utf-8');
|
/** Return the most recent snapshot across all targets, or null. */
|
||||||
return { path: latest, body, meta: parseFrontmatter(body) };
|
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
|
||||||
|
return readLatestSnapshotMatching('.md', cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,7 +143,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
|||||||
* Critique appends a one-line trend to its output using this.
|
* Critique appends a one-line trend to its output using this.
|
||||||
*/
|
*/
|
||||||
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
||||||
const all = listSnapshotsForSlug(slug, cwd);
|
const all = listSnapshots(`__${slug}.md`, cwd);
|
||||||
const slice = all.slice(-limit);
|
const slice = all.slice(-limit);
|
||||||
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
|
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,10 +142,73 @@ function stripInlineYamlComment(s) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YAML double-quoted scalars process backslash escapes. Stripping the outer
|
||||||
|
// quotes without unescaping leaves them in place, so a nested font family like
|
||||||
|
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
|
||||||
|
// reaches allowedFonts as '\"ibm plex sans' and never matches the same family
|
||||||
|
// declared in CSS. Scanner instead of a regex: the escape set is small and the
|
||||||
|
// backslash handling stays readable.
|
||||||
|
// The full YAML 1.2 double-quote escape set (spec section 5.7).
|
||||||
|
const YAML_SIMPLE_ESCAPES = {
|
||||||
|
'0': '\0',
|
||||||
|
a: '\x07',
|
||||||
|
b: '\b',
|
||||||
|
t: '\t',
|
||||||
|
n: '\n',
|
||||||
|
v: '\v',
|
||||||
|
f: '\f',
|
||||||
|
r: '\r',
|
||||||
|
e: '\x1b',
|
||||||
|
' ': ' ',
|
||||||
|
'"': '"',
|
||||||
|
'/': '/',
|
||||||
|
'\\': '\\',
|
||||||
|
N: '\u0085',
|
||||||
|
_: '\u00a0',
|
||||||
|
L: '\u2028',
|
||||||
|
P: '\u2029',
|
||||||
|
};
|
||||||
|
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
|
||||||
|
|
||||||
|
function unescapeYamlDoubleQuoted(body) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
const ch = body[i];
|
||||||
|
if (ch !== '\\' || i === body.length - 1) {
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = body[i + 1];
|
||||||
|
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
|
||||||
|
out += YAML_SIMPLE_ESCAPES[next];
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
|
||||||
|
// literal rather than corrupting the rest of the scalar.
|
||||||
|
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
|
||||||
|
if (hexLen) {
|
||||||
|
const hex = body.slice(i + 2, i + 2 + hexLen);
|
||||||
|
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
|
||||||
|
if (codePoint >= 0 && codePoint <= 0x10ffff) {
|
||||||
|
out += String.fromCodePoint(codePoint);
|
||||||
|
i += 1 + hexLen;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function parseScalar(raw) {
|
function parseScalar(raw) {
|
||||||
const s = raw.trim();
|
const s = raw.trim();
|
||||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||||
return s.slice(1, -1);
|
return unescapeYamlDoubleQuoted(s.slice(1, -1));
|
||||||
|
}
|
||||||
|
// Single-quoted YAML escapes only the quote itself, by doubling it.
|
||||||
|
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
|
||||||
|
return s.slice(1, -1).split("''").join("'");
|
||||||
}
|
}
|
||||||
if (s === 'true') return true;
|
if (s === 'true') return true;
|
||||||
if (s === 'false') return false;
|
if (s === 'false') return false;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.m
|
|||||||
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
||||||
export {
|
export {
|
||||||
walkDir,
|
walkDir,
|
||||||
|
hasScannableExtension,
|
||||||
SCANNABLE_EXTENSIONS,
|
SCANNABLE_EXTENSIONS,
|
||||||
SKIP_DIRS,
|
SKIP_DIRS,
|
||||||
buildImportGraph,
|
buildImportGraph,
|
||||||
|
|||||||
@@ -41,6 +41,221 @@ function shouldRunPageAnalyzers(content, filePath) {
|
|||||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
||||||
|
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
|
||||||
|
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
|
||||||
|
|
||||||
|
function isInsideOpeningJsxTag(source) {
|
||||||
|
const tagStart = source.lastIndexOf('<');
|
||||||
|
if (tagStart === -1 || !/^<[A-Za-z][\w.:-]*/.test(source.slice(tagStart))) return false;
|
||||||
|
|
||||||
|
let quote = '';
|
||||||
|
for (let cursor = tagStart + 1; cursor < source.length; cursor++) {
|
||||||
|
const char = source[cursor];
|
||||||
|
if (quote) {
|
||||||
|
if (char === '\\') cursor++;
|
||||||
|
else if (char === quote) quote = '';
|
||||||
|
} else if (char === "'" || char === '"') {
|
||||||
|
quote = char;
|
||||||
|
} else if (char === '>') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blank JavaScript comments without moving any following source. Regex
|
||||||
|
* findings keep their original line numbers, while prose examples inside
|
||||||
|
* comments cannot masquerade as rendered markup.
|
||||||
|
*/
|
||||||
|
function stripJsComments(content, options = {}) {
|
||||||
|
let state = 'code';
|
||||||
|
let output = '';
|
||||||
|
let lastSignificant = '';
|
||||||
|
let previousSignificant = '';
|
||||||
|
let antePreviousSignificant = '';
|
||||||
|
let currentWord = '';
|
||||||
|
let currentWordPrefix = '';
|
||||||
|
let wordSeparated = false;
|
||||||
|
let regexCharClass = false;
|
||||||
|
let jsxExpressionDepth = 0;
|
||||||
|
let lastClosedBraceKind = '';
|
||||||
|
const braceKinds = [];
|
||||||
|
const templateExpressionDepths = [];
|
||||||
|
|
||||||
|
const braceKind = (startsJsxExpression = false) => (
|
||||||
|
!startsJsxExpression && (
|
||||||
|
!lastSignificant ||
|
||||||
|
lastSignificant === ')' ||
|
||||||
|
lastSignificant === ';' ||
|
||||||
|
lastSignificant === '}' ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
||||||
|
) ? 'block' : 'expression'
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordSignificant = (char) => {
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
wordSeparated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isWordChar = /[\w$]/.test(char);
|
||||||
|
if (isWordChar && (wordSeparated || !currentWord)) {
|
||||||
|
currentWord = '';
|
||||||
|
currentWordPrefix = lastSignificant;
|
||||||
|
} else if (!isWordChar) {
|
||||||
|
currentWordPrefix = '';
|
||||||
|
}
|
||||||
|
wordSeparated = false;
|
||||||
|
antePreviousSignificant = previousSignificant;
|
||||||
|
previousSignificant = lastSignificant;
|
||||||
|
lastSignificant = char;
|
||||||
|
currentWord = isWordChar ? currentWord + char : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
const char = content[i];
|
||||||
|
const next = content[i + 1];
|
||||||
|
|
||||||
|
if (state === 'line-comment') {
|
||||||
|
if (char === '\n') {
|
||||||
|
output += char;
|
||||||
|
state = 'code';
|
||||||
|
} else {
|
||||||
|
output += ' ';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'block-comment') {
|
||||||
|
if (char === '*' && next === '/') {
|
||||||
|
output += ' ';
|
||||||
|
i++;
|
||||||
|
state = 'code';
|
||||||
|
} else {
|
||||||
|
output += char === '\n' ? '\n' : ' ';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'regex') {
|
||||||
|
output += char;
|
||||||
|
if (char === '\\' && next) {
|
||||||
|
output += next;
|
||||||
|
i++;
|
||||||
|
} else if (char === '[') {
|
||||||
|
regexCharClass = true;
|
||||||
|
} else if (char === ']') {
|
||||||
|
regexCharClass = false;
|
||||||
|
} else if (char === '/' && !regexCharClass) {
|
||||||
|
state = 'code';
|
||||||
|
recordSignificant('/');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'template' && char === '$' && next === '{') {
|
||||||
|
output += '${';
|
||||||
|
i++;
|
||||||
|
recordSignificant('$');
|
||||||
|
recordSignificant('{');
|
||||||
|
templateExpressionDepths.push(1);
|
||||||
|
braceKinds.push('expression');
|
||||||
|
if (jsxExpressionDepth) jsxExpressionDepth++;
|
||||||
|
state = 'code';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== 'code') {
|
||||||
|
output += char;
|
||||||
|
if (char === '\\' && next) {
|
||||||
|
output += next;
|
||||||
|
i++;
|
||||||
|
} else if (
|
||||||
|
(state === 'single-quote' && char === "'") ||
|
||||||
|
(state === 'double-quote' && char === '"') ||
|
||||||
|
(state === 'template' && char === '`')
|
||||||
|
) {
|
||||||
|
state = 'code';
|
||||||
|
recordSignificant(char);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsxUrlSeparator = options.jsx && char === '/' && next === '/' &&
|
||||||
|
jsxExpressionDepth === 0 &&
|
||||||
|
(output.endsWith('http:') ||
|
||||||
|
output.endsWith('https:') ||
|
||||||
|
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1)) &&
|
||||||
|
/^[\w.-]+\.[A-Za-z]{2,}(?=[:/?#\s<]|$)/.test(content.slice(i + 2))));
|
||||||
|
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
||||||
|
previousSignificant === lastSignificant &&
|
||||||
|
antePreviousSignificant !== lastSignificant;
|
||||||
|
if (char === '/' && next === '/' && jsxUrlSeparator) {
|
||||||
|
output += '//';
|
||||||
|
i++;
|
||||||
|
recordSignificant('/');
|
||||||
|
recordSignificant('/');
|
||||||
|
} else if (char === '/' && next === '/') {
|
||||||
|
output += ' ';
|
||||||
|
i++;
|
||||||
|
state = 'line-comment';
|
||||||
|
} else if (char === '/' && next === '*') {
|
||||||
|
output += ' ';
|
||||||
|
i++;
|
||||||
|
state = 'block-comment';
|
||||||
|
} else if (templateExpressionDepths.length && char === '{') {
|
||||||
|
output += char;
|
||||||
|
templateExpressionDepths[templateExpressionDepths.length - 1]++;
|
||||||
|
braceKinds.push(braceKind());
|
||||||
|
if (jsxExpressionDepth) jsxExpressionDepth++;
|
||||||
|
recordSignificant(char);
|
||||||
|
} else if (templateExpressionDepths.length && char === '}') {
|
||||||
|
output += char;
|
||||||
|
const depthIndex = templateExpressionDepths.length - 1;
|
||||||
|
templateExpressionDepths[depthIndex]--;
|
||||||
|
lastClosedBraceKind = braceKinds.pop() || '';
|
||||||
|
if (jsxExpressionDepth) jsxExpressionDepth--;
|
||||||
|
recordSignificant(char);
|
||||||
|
if (templateExpressionDepths[depthIndex] === 0) {
|
||||||
|
templateExpressionDepths.pop();
|
||||||
|
state = 'template';
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
char === '/' &&
|
||||||
|
(!lastSignificant ||
|
||||||
|
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
||||||
|
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
||||||
|
) {
|
||||||
|
output += char;
|
||||||
|
state = 'regex';
|
||||||
|
regexCharClass = false;
|
||||||
|
} else {
|
||||||
|
output += char;
|
||||||
|
const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 &&
|
||||||
|
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1, -1)) ||
|
||||||
|
isInsideOpeningJsxTag(output.slice(0, -1)));
|
||||||
|
if (char === '{') braceKinds.push(braceKind(startsJsxExpression));
|
||||||
|
else if (char === '}') lastClosedBraceKind = braceKinds.pop() || '';
|
||||||
|
if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth++;
|
||||||
|
else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth--;
|
||||||
|
recordSignificant(char);
|
||||||
|
if (char === "'") state = 'single-quote';
|
||||||
|
else if (char === '"') state = 'double-quote';
|
||||||
|
else if (char === '`') state = 'template';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCssComments(content) {
|
||||||
|
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
|
||||||
|
}
|
||||||
|
|
||||||
function firstOverusedGoogleFont(text) {
|
function firstOverusedGoogleFont(text) {
|
||||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||||
}
|
}
|
||||||
@@ -528,18 +743,198 @@ function extractStyleBlocks(content, ext) {
|
|||||||
|
|
||||||
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
||||||
|
|
||||||
|
function findQuotedStringEnd(content, start, quote) {
|
||||||
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||||
|
if (content[cursor] === '\\') cursor++;
|
||||||
|
else if (content[cursor] === quote) return cursor;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRegexLiteralEnd(content, start) {
|
||||||
|
let inCharacterClass = false;
|
||||||
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||||
|
const char = content[cursor];
|
||||||
|
if (char === '\\') {
|
||||||
|
cursor++;
|
||||||
|
} else if (char === '[') {
|
||||||
|
inCharacterClass = true;
|
||||||
|
} else if (char === ']') {
|
||||||
|
inCharacterClass = false;
|
||||||
|
} else if (char === '/' && !inCharacterClass) {
|
||||||
|
while (/[A-Za-z]/.test(content[cursor + 1] || '')) cursor++;
|
||||||
|
return cursor;
|
||||||
|
} else if (char === '\n' || char === '\r') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTemplateExpressionEnd(content, start) {
|
||||||
|
let depth = 1;
|
||||||
|
let lastSignificant = '';
|
||||||
|
let previousSignificant = '';
|
||||||
|
let antePreviousSignificant = '';
|
||||||
|
let currentWord = '';
|
||||||
|
let currentWordPrefix = '';
|
||||||
|
let wordSeparated = false;
|
||||||
|
let lastClosedBraceKind = '';
|
||||||
|
const braceKinds = [];
|
||||||
|
|
||||||
|
const braceKind = () => (
|
||||||
|
lastSignificant === ')' ||
|
||||||
|
lastSignificant === ';' ||
|
||||||
|
lastSignificant === '}' ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
||||||
|
? 'block'
|
||||||
|
: 'expression'
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordSignificant = (char) => {
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
wordSeparated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isWordChar = /[\w$]/.test(char);
|
||||||
|
if (isWordChar && (wordSeparated || !currentWord)) {
|
||||||
|
currentWord = '';
|
||||||
|
currentWordPrefix = lastSignificant;
|
||||||
|
} else if (!isWordChar) {
|
||||||
|
currentWordPrefix = '';
|
||||||
|
}
|
||||||
|
wordSeparated = false;
|
||||||
|
antePreviousSignificant = previousSignificant;
|
||||||
|
previousSignificant = lastSignificant;
|
||||||
|
lastSignificant = char;
|
||||||
|
currentWord = isWordChar ? currentWord + char : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let cursor = start; cursor < content.length; cursor++) {
|
||||||
|
const char = content[cursor];
|
||||||
|
const next = content[cursor + 1];
|
||||||
|
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
||||||
|
previousSignificant === lastSignificant &&
|
||||||
|
antePreviousSignificant !== lastSignificant;
|
||||||
|
if (char === "'" || char === '"') {
|
||||||
|
cursor = findQuotedStringEnd(content, cursor, char);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
recordSignificant(')');
|
||||||
|
} else if (char === '/' && next === '/') {
|
||||||
|
const lineEnd = content.indexOf('\n', cursor + 2);
|
||||||
|
if (lineEnd === -1) return -1;
|
||||||
|
cursor = lineEnd;
|
||||||
|
} else if (char === '/' && next === '*') {
|
||||||
|
const commentEnd = content.indexOf('*/', cursor + 2);
|
||||||
|
if (commentEnd === -1) return -1;
|
||||||
|
cursor = commentEnd + 1;
|
||||||
|
} else if (
|
||||||
|
char === '/' &&
|
||||||
|
(!lastSignificant ||
|
||||||
|
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
||||||
|
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
||||||
|
) {
|
||||||
|
cursor = findRegexLiteralEnd(content, cursor);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
recordSignificant(')');
|
||||||
|
} else if (char === '`') {
|
||||||
|
cursor = findTemplateLiteralEnd(content, cursor);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
recordSignificant(')');
|
||||||
|
} else if (char === '{') {
|
||||||
|
depth++;
|
||||||
|
braceKinds.push(braceKind());
|
||||||
|
recordSignificant(char);
|
||||||
|
} else if (char === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return cursor;
|
||||||
|
lastClosedBraceKind = braceKinds.pop() || '';
|
||||||
|
recordSignificant(char);
|
||||||
|
} else {
|
||||||
|
recordSignificant(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTemplateLiteralEnd(content, start) {
|
||||||
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||||
|
const char = content[cursor];
|
||||||
|
if (char === '\\') {
|
||||||
|
cursor++;
|
||||||
|
} else if (char === '`') {
|
||||||
|
return cursor;
|
||||||
|
} else if (char === '$' && content[cursor + 1] === '{') {
|
||||||
|
cursor = findTemplateExpressionEnd(content, cursor + 2);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCSSinJSTemplates(content) {
|
||||||
|
const templates = [];
|
||||||
|
const tagRe = /\b(?:styled(?:\.\w+|\([^)]+\))|css)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = tagRe.exec(content)) !== null) {
|
||||||
|
let cursor = match.index + match[0].length;
|
||||||
|
while (/\s/.test(content[cursor] || '')) cursor++;
|
||||||
|
|
||||||
|
if (content[cursor] === '<') {
|
||||||
|
let depth = 0;
|
||||||
|
while (cursor < content.length) {
|
||||||
|
const char = content[cursor];
|
||||||
|
if (char === '<') depth++;
|
||||||
|
else if (char === '>' && content[cursor - 1] !== '=') depth--;
|
||||||
|
cursor++;
|
||||||
|
if (depth === 0) break;
|
||||||
|
}
|
||||||
|
if (depth !== 0) continue;
|
||||||
|
while (/\s/.test(content[cursor] || '')) cursor++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content[cursor] !== '`') continue;
|
||||||
|
const contentStart = cursor + 1;
|
||||||
|
cursor = findTemplateLiteralEnd(content, cursor);
|
||||||
|
if (cursor === -1) continue;
|
||||||
|
|
||||||
|
templates.push({
|
||||||
|
tagStart: match.index,
|
||||||
|
contentStart,
|
||||||
|
contentEnd: cursor,
|
||||||
|
});
|
||||||
|
tagRe.lastIndex = cursor + 1;
|
||||||
|
}
|
||||||
|
return templates;
|
||||||
|
}
|
||||||
|
|
||||||
function extractCSSinJS(content, ext) {
|
function extractCSSinJS(content, ext) {
|
||||||
ext = ext.toLowerCase();
|
ext = ext.toLowerCase();
|
||||||
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
||||||
const blocks = [];
|
return findCSSinJSTemplates(content).map((template) => {
|
||||||
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
|
const before = content.substring(0, template.tagStart);
|
||||||
let m;
|
|
||||||
while ((m = re.exec(content)) !== null) {
|
|
||||||
const before = content.substring(0, m.index);
|
|
||||||
const startLine = before.split('\n').length;
|
const startLine = before.split('\n').length;
|
||||||
blocks.push({ content: m[1], startLine });
|
return {
|
||||||
|
content: content.slice(template.contentStart, template.contentEnd),
|
||||||
|
startLine,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCssInJsComments(content, ext) {
|
||||||
|
if (!CSS_IN_JS_EXTENSIONS.has(ext.toLowerCase())) return content;
|
||||||
|
const templates = findCSSinJSTemplates(content);
|
||||||
|
let output = '';
|
||||||
|
let cursor = 0;
|
||||||
|
for (const template of templates) {
|
||||||
|
output += content.slice(cursor, template.contentStart);
|
||||||
|
output += stripCssComments(content.slice(template.contentStart, template.contentEnd));
|
||||||
|
cursor = template.contentEnd;
|
||||||
}
|
}
|
||||||
return blocks;
|
return output + content.slice(cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
|
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
|
||||||
@@ -627,8 +1022,12 @@ function runTextContentAnalyzers(content, filePath, options = {}) {
|
|||||||
function detectText(content, filePath, options = {}) {
|
function detectText(content, filePath, options = {}) {
|
||||||
const profile = options?.profile;
|
const profile = options?.profile;
|
||||||
const findings = [];
|
const findings = [];
|
||||||
const lines = content.split('\n');
|
|
||||||
const ext = extFromFilePath(filePath);
|
const ext = extFromFilePath(filePath);
|
||||||
|
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
|
||||||
|
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
|
||||||
|
}) : content;
|
||||||
|
const source = stripCssInJsComments(commentStrippedSource, ext);
|
||||||
|
const lines = source.split('\n');
|
||||||
|
|
||||||
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
||||||
// Enable block context for CSS files where related properties span multiple lines
|
// Enable block context for CSS files where related properties span multiple lines
|
||||||
@@ -661,8 +1060,8 @@ function detectText(content, filePath, options = {}) {
|
|||||||
phase: 'source',
|
phase: 'source',
|
||||||
ruleId: 'codex-grid-background',
|
ruleId: 'codex-grid-background',
|
||||||
target: filePath,
|
target: filePath,
|
||||||
}, () => scanCssTextForGridBackground(content).map(hit => {
|
}, () => scanCssTextForGridBackground(source).map(hit => {
|
||||||
const line = content.substring(0, hit.index).split('\n').length;
|
const line = source.substring(0, hit.index).split('\n').length;
|
||||||
return finding('codex-grid-background', filePath, hit.snippet, line);
|
return finding('codex-grid-background', filePath, hit.snippet, line);
|
||||||
})));
|
})));
|
||||||
|
|
||||||
@@ -698,16 +1097,17 @@ function detectText(content, filePath, options = {}) {
|
|||||||
phase: 'extract',
|
phase: 'extract',
|
||||||
ruleId: 'css-in-js',
|
ruleId: 'css-in-js',
|
||||||
target: filePath,
|
target: filePath,
|
||||||
}, () => extractCSSinJS(content, ext))
|
}, () => extractCSSinJS(source, ext))
|
||||||
: extractCSSinJS(content, ext);
|
: extractCSSinJS(source, ext);
|
||||||
for (const block of cssJsBlocks) {
|
for (const block of cssJsBlocks) {
|
||||||
const blockLines = block.content.split('\n');
|
const blockContent = stripCssComments(block.content);
|
||||||
|
const blockLines = blockContent.split('\n');
|
||||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||||
profile,
|
profile,
|
||||||
phase: 'css-in-js',
|
phase: 'css-in-js',
|
||||||
}));
|
}));
|
||||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
|
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 1));
|
||||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
|
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.designSystem) {
|
if (options?.designSystem) {
|
||||||
|
|||||||
@@ -26,11 +26,26 @@ const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
|
|||||||
const SCANNABLE_EXTENSIONS = new Set([
|
const SCANNABLE_EXTENSIONS = new Set([
|
||||||
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
||||||
'.jsx', '.tsx', '.js', '.ts',
|
'.jsx', '.tsx', '.js', '.ts',
|
||||||
'.vue', '.svelte', '.astro',
|
'.vue', '.svelte', '.astro', '.blade.php',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
||||||
|
|
||||||
|
function hasScannableExtension(filename) {
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
|
||||||
|
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||||
|
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORT_SPECIFIER_PATTERNS = [
|
||||||
|
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
|
||||||
|
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
|
||||||
|
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||||
|
];
|
||||||
|
|
||||||
function walkDir(dir) {
|
function walkDir(dir) {
|
||||||
const files = [];
|
const files = [];
|
||||||
let entries;
|
let entries;
|
||||||
@@ -40,7 +55,7 @@ function walkDir(dir) {
|
|||||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||||
const full = path.join(dir, entry.name);
|
const full = path.join(dir, entry.name);
|
||||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||||
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
|
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
@@ -75,26 +90,11 @@ function buildImportGraph(files) {
|
|||||||
const dir = path.dirname(file);
|
const dir = path.dirname(file);
|
||||||
const imports = new Set();
|
const imports = new Set();
|
||||||
|
|
||||||
// ES imports: import ... from '...' and import '...'
|
for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
|
||||||
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
|
for (const match of content.matchAll(pattern)) {
|
||||||
let m;
|
const resolved = resolveImport(match[1], dir, fileSet);
|
||||||
while ((m = esRe.exec(content)) !== null) {
|
if (resolved) imports.add(resolved);
|
||||||
const resolved = resolveImport(m[1], dir, fileSet);
|
}
|
||||||
if (resolved) imports.add(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSS @import
|
|
||||||
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
|
|
||||||
while ((m = cssRe.exec(content)) !== null) {
|
|
||||||
const resolved = resolveImport(m[1], dir, fileSet);
|
|
||||||
if (resolved) imports.add(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SCSS @use / @forward
|
|
||||||
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
|
|
||||||
while ((m = scssRe.exec(content)) !== null) {
|
|
||||||
const resolved = resolveImport(m[1], dir, fileSet);
|
|
||||||
if (resolved) imports.add(resolved);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
graph.set(file, imports);
|
graph.set(file, imports);
|
||||||
@@ -203,6 +203,7 @@ export {
|
|||||||
SKIP_DIRS,
|
SKIP_DIRS,
|
||||||
SCANNABLE_EXTENSIONS,
|
SCANNABLE_EXTENSIONS,
|
||||||
HTML_EXTENSIONS,
|
HTML_EXTENSIONS,
|
||||||
|
hasScannableExtension,
|
||||||
walkDir,
|
walkDir,
|
||||||
resolveImport,
|
resolveImport,
|
||||||
buildImportGraph,
|
buildImportGraph,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* node hook-admin.mjs off # set enabled: false
|
* node hook-admin.mjs off # set enabled: false
|
||||||
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
|
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
|
||||||
* node hook-admin.mjs ignore-rule overused-font --all-values
|
* node hook-admin.mjs ignore-rule overused-font --all-values
|
||||||
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
|
* node hook-admin.mjs ignore-file <glob> [--shared|--local] # append to ignoreFiles
|
||||||
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
||||||
* node hook-admin.mjs ignore-value <rule> <value> --local
|
* node hook-admin.mjs ignore-value <rule> <value> --local
|
||||||
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
|
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
|
||||||
@@ -166,7 +166,7 @@ function readRawConfigFile(filePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
|
||||||
|
|
||||||
function hookSection(unified) {
|
function hookSection(unified) {
|
||||||
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
|
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
|
||||||
@@ -200,6 +200,15 @@ function stripDetectorKeys(raw) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickDetectorKeys(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||||
|
const out = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Write hook runtime config under `hook`, leaving detector filters in
|
// Write hook runtime config under `hook`, leaving detector filters in
|
||||||
// `detector` and preserving sibling keys such as updateCheck.
|
// `detector` and preserving sibling keys such as updateCheck.
|
||||||
function writeHookConfig(cwd, hookConfig, opts = {}) {
|
function writeHookConfig(cwd, hookConfig, opts = {}) {
|
||||||
@@ -207,10 +216,19 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
|
|||||||
if (opts.local) ensureHookGitExcludes(cwd);
|
if (opts.local) ensureHookGitExcludes(cwd);
|
||||||
const existingRaw = readRawConfigFile(filePath).raw;
|
const existingRaw = readRawConfigFile(filePath).raw;
|
||||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||||
const existingHook = stripDetectorKeys(hookSection(existing));
|
const existingHookSection = hookSection(existing);
|
||||||
|
const existingHook = stripDetectorKeys(existingHookSection);
|
||||||
|
const legacyDetector = pickDetectorKeys(existingHookSection);
|
||||||
// Merge over the existing hook object so fields the merge helpers don't manage
|
// Merge over the existing hook object so fields the merge helpers don't manage
|
||||||
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
|
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
|
||||||
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
||||||
|
if (Object.keys(legacyDetector).length > 0) {
|
||||||
|
const existingDetector = detectorSection(existing) || {};
|
||||||
|
next.detector = {
|
||||||
|
...existingDetector,
|
||||||
|
...mergeDetectorConfig(existingDetector, mergeDetectorConfig(legacyDetector)),
|
||||||
|
};
|
||||||
|
}
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
||||||
return filePath;
|
return filePath;
|
||||||
@@ -222,10 +240,14 @@ function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
|
|||||||
const existingRaw = readRawConfigFile(filePath).raw;
|
const existingRaw = readRawConfigFile(filePath).raw;
|
||||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||||
const nextHook = stripDetectorKeys(hookSection(existing));
|
const nextHook = stripDetectorKeys(hookSection(existing));
|
||||||
const existingDetector = mergeDetectorConfig(detectorSection(existing));
|
const existingDetectorSection = detectorSection(existing) || {};
|
||||||
|
const existingDetector = mergeDetectorConfig(existingDetectorSection);
|
||||||
const next = {
|
const next = {
|
||||||
...existing,
|
...existing,
|
||||||
detector: mergeDetectorConfig(detectorConfig, existingDetector),
|
detector: {
|
||||||
|
...existingDetectorSection,
|
||||||
|
...mergeDetectorConfig(detectorConfig, existingDetector),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
|
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
|
||||||
else delete next.hook;
|
else delete next.hook;
|
||||||
@@ -259,12 +281,18 @@ function mergeDetectorConfig(existing, seed = null) {
|
|||||||
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
|
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
|
||||||
out.designSystem = { ...seed.designSystem };
|
out.designSystem = { ...seed.designSystem };
|
||||||
}
|
}
|
||||||
|
if (seed?.advisoryRules === 'include' || seed?.advisoryRules === 'exclude') {
|
||||||
|
out.advisoryRules = seed.advisoryRules;
|
||||||
|
}
|
||||||
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
|
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
|
||||||
out.designSystem = {
|
out.designSystem = {
|
||||||
...(out.designSystem || {}),
|
...(out.designSystem || {}),
|
||||||
enabled: base.designSystem.enabled === false ? false : true,
|
enabled: base.designSystem.enabled === false ? false : true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (base.advisoryRules === 'include' || base.advisoryRules === 'exclude') {
|
||||||
|
out.advisoryRules = base.advisoryRules;
|
||||||
|
}
|
||||||
if (Array.isArray(base.ignoreRules)) {
|
if (Array.isArray(base.ignoreRules)) {
|
||||||
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
|
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
|
||||||
}
|
}
|
||||||
@@ -558,12 +586,44 @@ function addIgnoreRule(cwd, args) {
|
|||||||
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
|
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addIgnoreFile(cwd, glob) {
|
function parseIgnoreFileArgs(args) {
|
||||||
|
const positionals = [];
|
||||||
|
let shared = false;
|
||||||
|
let local = false;
|
||||||
|
|
||||||
|
for (const raw of args) {
|
||||||
|
const arg = String(raw || '');
|
||||||
|
if (arg === '--shared') {
|
||||||
|
shared = true;
|
||||||
|
} else if (arg === '--local') {
|
||||||
|
local = true;
|
||||||
|
} else if (arg === '--reason' || arg.startsWith('--reason=')) {
|
||||||
|
throw new Error('--reason is not supported for ignore-file because detector.ignoreFiles stores globs only; use ignore-value when a documented rule-specific exception fits');
|
||||||
|
} else if (arg.startsWith('--')) {
|
||||||
|
throw new Error(`Unknown ignore-file flag: ${arg}`);
|
||||||
|
} else {
|
||||||
|
positionals.push(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shared && local) throw new Error('Pass only one scope flag: --shared or --local');
|
||||||
|
if (positionals.length > 1) throw new Error('Pass exactly one glob to ignore-file');
|
||||||
|
|
||||||
|
return {
|
||||||
|
glob: positionals[0],
|
||||||
|
local,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIgnoreFile(cwd, args) {
|
||||||
|
const parsed = parseIgnoreFileArgs(args);
|
||||||
|
const glob = parsed.glob;
|
||||||
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
|
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
|
||||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
|
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local: parsed.local }));
|
||||||
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
||||||
writeDetectorConfig(cwd, config);
|
const target = writeDetectorConfig(cwd, config, { local: parsed.local });
|
||||||
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
|
const scope = parsed.local ? 'local detector.ignoreFiles' : 'shared detector.ignoreFiles';
|
||||||
|
return `Added "${glob}" to ${scope} (${path.relative(cwd, target) || target}). Current: ${config.ignoreFiles.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
|
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
|
||||||
@@ -727,7 +787,7 @@ function main() {
|
|||||||
case 'on': out = setEnabled(cwd, true); break;
|
case 'on': out = setEnabled(cwd, true); break;
|
||||||
case 'off': out = setEnabled(cwd, false); break;
|
case 'off': out = setEnabled(cwd, false); break;
|
||||||
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
|
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
|
||||||
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
|
case 'ignore-file': out = addIgnoreFile(cwd, rest); break;
|
||||||
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
|
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
|
||||||
case 'reset': out = reset(cwd); break;
|
case 'reset': out = reset(cwd); break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
designSystemOptions,
|
designSystemOptions,
|
||||||
filterFindings,
|
filterFindings,
|
||||||
isNativePlatform,
|
isNativePlatform,
|
||||||
|
isScanTargetInsideProject,
|
||||||
loadDetector,
|
loadDetector,
|
||||||
matchConfiguredExtension,
|
matchConfiguredExtension,
|
||||||
matchesAnyGlob,
|
matchesAnyGlob,
|
||||||
@@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readExistingProjectFile(filePath, cwd) {
|
function readExistingProjectFile(filePath, cwd) {
|
||||||
if (!isInsideProject(filePath, cwd)) return null;
|
if (!isScanTargetInsideProject(filePath, cwd)) return null;
|
||||||
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
|
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
@@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) {
|
|||||||
const source = shellCopyPaths(command)?.source;
|
const source = shellCopyPaths(command)?.source;
|
||||||
if (!source) return '';
|
if (!source) return '';
|
||||||
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
|
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
|
||||||
if (!isInsideProject(sourcePath, cwd)) return '';
|
if (!isScanTargetInsideProject(sourcePath, cwd)) return '';
|
||||||
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
|
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(sourcePath);
|
const stat = fs.statSync(sourcePath);
|
||||||
@@ -328,15 +329,6 @@ function relativePath(filePath, cwd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInsideProject(filePath, cwd) {
|
|
||||||
try {
|
|
||||||
const rel = path.relative(cwd, filePath);
|
|
||||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||||
@@ -414,7 +406,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
|
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
|
||||||
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
||||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||||
|
|
||||||
|
|||||||
@@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve a path to its canonical (symlink-free) form. When the path does
|
||||||
|
// not exist yet — the before-edit hook gates proposed Writes — canonicalize
|
||||||
|
// the nearest existing ancestor and re-append the remainder, so a new file
|
||||||
|
// under a symlinked root still compares equal to its canonical project.
|
||||||
|
// Memoized: the hook runs as a fresh process per tool event, so the cache
|
||||||
|
// amounts to once-per-event work — the scan loops re-check the same project
|
||||||
|
// root for every target file. The cap only matters to long-lived importers
|
||||||
|
// like the test runner.
|
||||||
|
const canonicalPathCache = new Map();
|
||||||
|
const CANONICAL_PATH_CACHE_MAX = 1024;
|
||||||
|
|
||||||
|
function canonicalPath(p) {
|
||||||
|
const resolved = path.resolve(p);
|
||||||
|
if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved);
|
||||||
|
let canonical = resolved;
|
||||||
|
let dir = resolved;
|
||||||
|
const tail = [];
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir);
|
||||||
|
break;
|
||||||
|
} catch { /* keep climbing */ }
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) break;
|
||||||
|
tail.unshift(path.basename(dir));
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear();
|
||||||
|
canonicalPathCache.set(resolved, canonical);
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Containment gate shared by the before-edit hook and both scan passes. A
|
||||||
|
// session routinely touches files that belong to no project or to a
|
||||||
|
// different one — harness scratchpad dirs under the system temp root,
|
||||||
|
// sibling checkouts, one-off throwaway HTML — and findings against those are
|
||||||
|
// judged with THIS project's config and DESIGN.md palette, which is never
|
||||||
|
// right. Skip them (audit reason: outside-project). Paths are canonicalized
|
||||||
|
// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the
|
||||||
|
// comparison.
|
||||||
|
export function isScanTargetInsideProject(filePath, projectCwd) {
|
||||||
|
if (!filePath || !projectCwd) return false;
|
||||||
|
return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd));
|
||||||
|
}
|
||||||
|
|
||||||
export function parseStaticStyleImports(content, fromFile, projectCwd) {
|
export function parseStaticStyleImports(content, fromFile, projectCwd) {
|
||||||
if (!content || typeof content !== 'string') return [];
|
if (!content || typeof content !== 'string') return [];
|
||||||
const dir = path.dirname(fromFile);
|
const dir = path.dirname(fromFile);
|
||||||
@@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|||||||
lastSkip = 'file-missing';
|
lastSkip = 'file-missing';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!isScanTargetInsideProject(filePath, projectCwd)) {
|
||||||
|
lastSkip = 'outside-project';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
|
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
|
||||||
if (maxFileBytes > 0) {
|
if (maxFileBytes > 0) {
|
||||||
@@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
|||||||
const relForMatch = relativize(filePath, projectCwd);
|
const relForMatch = relativize(filePath, projectCwd);
|
||||||
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
|
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
|
||||||
if (!fs.existsSync(filePath)) continue;
|
if (!fs.existsSync(filePath)) continue;
|
||||||
|
// Caches written before this gate existed can still hold out-of-project
|
||||||
|
// paths, so the Stop pass re-checks containment rather than trusting
|
||||||
|
// the per-edit pass to have filtered them.
|
||||||
|
if (!isScanTargetInsideProject(filePath, projectCwd)) continue;
|
||||||
|
|
||||||
scanned += 1;
|
scanned += 1;
|
||||||
let content = '';
|
let content = '';
|
||||||
|
|||||||
@@ -50,9 +50,36 @@ export function normalizeConceptForm(value) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateConceptEntry(concept, { existingForms = new Map() } = {}) {
|
export function validateConceptEntry(concept, { existingForms = new Map(), axes = null } = {}) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const id = concept?.id || '(unknown)';
|
const id = concept?.id || '(unknown)';
|
||||||
|
|
||||||
|
// Recorded aesthetic axis values. Optional, and absent means the value is
|
||||||
|
// inferred from the system rules instead. Some axes cannot be inferred at all:
|
||||||
|
// depth's keyword probe matched worlds that said "no cast shadow anywhere",
|
||||||
|
// and motion and colour strategy describe properties the rules never state, so
|
||||||
|
// a wave that assigns those has to record them or the assignment is lost.
|
||||||
|
// Validated against the axes definition when the caller supplies it, because a
|
||||||
|
// typo would read as "unrecorded" and silently fall back to a probe that is
|
||||||
|
// known not to work.
|
||||||
|
if (concept?.axes !== undefined && concept.axes !== null) {
|
||||||
|
if (typeof concept.axes !== 'object' || Array.isArray(concept.axes)) {
|
||||||
|
errors.push(`concept ${id} axes must be an object of axis id to value id`);
|
||||||
|
} else if (axes) {
|
||||||
|
const byId = new Map((axes.axes || []).map(axis => [axis.id, axis]));
|
||||||
|
for (const [axisId, valueId] of Object.entries(concept.axes)) {
|
||||||
|
const axis = byId.get(axisId);
|
||||||
|
if (!axis) {
|
||||||
|
errors.push(`concept ${id} names unknown axis "${axisId}"`);
|
||||||
|
} else if (!(axis.values || []).some(value => value.id === valueId)) {
|
||||||
|
errors.push(
|
||||||
|
`concept ${id} axis "${axisId}" has unknown value "${valueId}" `
|
||||||
|
+ `(expected one of ${(axis.values || []).map(v => v.id).join(', ')})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
|
||||||
errors.push(`invalid concept id: ${String(concept?.id)}`);
|
errors.push(`invalid concept id: ${String(concept?.id)}`);
|
||||||
}
|
}
|
||||||
@@ -82,6 +109,18 @@ export function validateConceptEntry(concept, { existingForms = new Map() } = {}
|
|||||||
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||||
errors.push(`concept ${id} must have exactly three structural tags`);
|
errors.push(`concept ${id} must have exactly three structural tags`);
|
||||||
}
|
}
|
||||||
|
// The slop this world in particular is at risk of. Optional, because 541
|
||||||
|
// entries predate it and none of them are wrong for lacking it. A world built
|
||||||
|
// from posters is at risk of shouting and one built from instruments is at
|
||||||
|
// risk of dead greys; a global detector cannot know which, and the author can.
|
||||||
|
if (concept?.avoid !== undefined) {
|
||||||
|
if (!Array.isArray(concept.avoid)
|
||||||
|
|| concept.avoid.length < 2
|
||||||
|
|| concept.avoid.length > 3
|
||||||
|
|| concept.avoid.some(item => typeof item !== 'string' || item.trim().length < 12 || item.trim().length > 160)) {
|
||||||
|
errors.push(`concept ${id} avoid must be two or three negations of 12–160 characters`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!Array.isArray(concept?.system)
|
if (!Array.isArray(concept?.system)
|
||||||
|| concept.system.length !== SYSTEM_PREFIXES.length
|
|| concept.system.length !== SYSTEM_PREFIXES.length
|
||||||
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
||||||
|
|||||||
@@ -2,15 +2,20 @@
|
|||||||
// the live-mode design-system panel can render. Deterministic, dependency-free.
|
// the live-mode design-system panel can render. Deterministic, dependency-free.
|
||||||
//
|
//
|
||||||
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
|
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
|
||||||
// (prose with six canonical H2 sections). When frontmatter is present, it's
|
// (prose with eight canonical H2 sections). When frontmatter is present, it's
|
||||||
// exposed on `model.frontmatter` alongside the prose-scraped sections;
|
// exposed on `model.frontmatter` alongside the prose-scraped sections;
|
||||||
// consumers can prefer frontmatter values and fall back to prose.
|
// consumers can prefer frontmatter values and fall back to prose.
|
||||||
|
|
||||||
|
// Array order is also match precedence: matchCanonicalSection's keyword-contained
|
||||||
|
// pass returns the first entry a heading contains, so reordering this changes
|
||||||
|
// which section an ambiguous heading resolves to.
|
||||||
const CANONICAL_SECTIONS = [
|
const CANONICAL_SECTIONS = [
|
||||||
'Overview',
|
'Overview',
|
||||||
'Colors',
|
'Colors',
|
||||||
'Typography',
|
'Typography',
|
||||||
|
'Layout',
|
||||||
'Elevation',
|
'Elevation',
|
||||||
|
'Shapes',
|
||||||
'Components',
|
'Components',
|
||||||
"Do's and Don'ts",
|
"Do's and Don'ts",
|
||||||
];
|
];
|
||||||
@@ -115,10 +120,71 @@ function stripInlineYamlComment(s) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YAML double-quoted scalars process backslash escapes. Stripping the outer
|
||||||
|
// quotes without unescaping leaves them in place, so a nested font family like
|
||||||
|
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
|
||||||
|
// keeps its literal backslashes and never matches the same family in CSS.
|
||||||
|
// The full YAML 1.2 double-quote escape set (spec section 5.7).
|
||||||
|
const YAML_SIMPLE_ESCAPES = {
|
||||||
|
'0': '\0',
|
||||||
|
a: '\x07',
|
||||||
|
b: '\b',
|
||||||
|
t: '\t',
|
||||||
|
n: '\n',
|
||||||
|
v: '\v',
|
||||||
|
f: '\f',
|
||||||
|
r: '\r',
|
||||||
|
e: '\x1b',
|
||||||
|
' ': ' ',
|
||||||
|
'"': '"',
|
||||||
|
'/': '/',
|
||||||
|
'\\': '\\',
|
||||||
|
N: '\u0085',
|
||||||
|
_: '\u00a0',
|
||||||
|
L: '\u2028',
|
||||||
|
P: '\u2029',
|
||||||
|
};
|
||||||
|
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
|
||||||
|
|
||||||
|
function unescapeYamlDoubleQuoted(body) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
const ch = body[i];
|
||||||
|
if (ch !== '\\' || i === body.length - 1) {
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = body[i + 1];
|
||||||
|
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
|
||||||
|
out += YAML_SIMPLE_ESCAPES[next];
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
|
||||||
|
// literal rather than corrupting the rest of the scalar.
|
||||||
|
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
|
||||||
|
if (hexLen) {
|
||||||
|
const hex = body.slice(i + 2, i + 2 + hexLen);
|
||||||
|
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
|
||||||
|
if (codePoint >= 0 && codePoint <= 0x10ffff) {
|
||||||
|
out += String.fromCodePoint(codePoint);
|
||||||
|
i += 1 + hexLen;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function parseScalar(raw) {
|
function parseScalar(raw) {
|
||||||
const s = raw.trim();
|
const s = raw.trim();
|
||||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||||
return s.slice(1, -1);
|
return unescapeYamlDoubleQuoted(s.slice(1, -1));
|
||||||
|
}
|
||||||
|
// Single-quoted YAML escapes only the quote itself, by doubling it.
|
||||||
|
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
|
||||||
|
return s.slice(1, -1).split("''").join("'");
|
||||||
}
|
}
|
||||||
if (s === 'true') return true;
|
if (s === 'true') return true;
|
||||||
if (s === 'false') return false;
|
if (s === 'false') return false;
|
||||||
@@ -330,17 +396,16 @@ function extractOverview(section) {
|
|||||||
if (!section) return null;
|
if (!section) return null;
|
||||||
const text = section.lines.join('\n');
|
const text = section.lines.join('\n');
|
||||||
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
|
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
|
||||||
const keyChars = [];
|
|
||||||
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
|
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
|
||||||
if (keyCharMatch) {
|
const keyChars = keyCharMatch
|
||||||
for (const line of keyCharMatch[1].split('\n')) {
|
? collectBullets(keyCharMatch[1].split('\n')).map((bullet) => stripBold(bullet.trim()))
|
||||||
const m = line.match(/^\s*[-*]\s+(.+)$/);
|
: [];
|
||||||
if (m) keyChars.push(stripBold(m[1].trim()));
|
const prose = keyCharMatch
|
||||||
}
|
? text.slice(0, keyCharMatch.index) + text.slice(keyCharMatch.index + keyCharMatch[0].length)
|
||||||
}
|
: text;
|
||||||
|
|
||||||
// Philosophy paragraphs: everything that isn't a rule header or key-char block
|
// Philosophy paragraphs: everything that isn't a rule header or key-char block
|
||||||
const paragraphs = collectParagraphs(section.lines).filter(
|
const paragraphs = collectParagraphs(prose.split('\n')).filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
!p.startsWith('**Creative North Star') &&
|
!p.startsWith('**Creative North Star') &&
|
||||||
!p.startsWith('**Key Characteristics')
|
!p.startsWith('**Key Characteristics')
|
||||||
@@ -602,11 +667,19 @@ function parseTypeBullet(bullet) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractElevation(section) {
|
function extractGuidance(section) {
|
||||||
if (!section) return null;
|
if (!section) return null;
|
||||||
const subs = splitSubsections(section.lines);
|
const subs = splitSubsections(section.lines);
|
||||||
|
return {
|
||||||
|
subtitle: section.subtitle,
|
||||||
|
description: collectParagraphs(subs[0].lines).join(' ') || null,
|
||||||
|
rules: extractNamedRules(section.lines),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const description = collectParagraphs(subs[0].lines).join(' ') || null;
|
function extractElevation(section) {
|
||||||
|
const guidance = extractGuidance(section);
|
||||||
|
if (!guidance) return null;
|
||||||
|
|
||||||
const shadows = [];
|
const shadows = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -631,12 +704,7 @@ function extractElevation(section) {
|
|||||||
for (const inline of extractInlineShadows(b)) dedupe(inline);
|
for (const inline of extractInlineShadows(b)) dedupe(inline);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { ...guidance, shadows };
|
||||||
subtitle: section.subtitle,
|
|
||||||
description,
|
|
||||||
shadows,
|
|
||||||
rules: extractNamedRules(section.lines),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractInlineShadows(text) {
|
function extractInlineShadows(text) {
|
||||||
@@ -768,6 +836,15 @@ function extractDosDonts(section) {
|
|||||||
|
|
||||||
// ---------- Coverage assessment ----------
|
// ---------- Coverage assessment ----------
|
||||||
|
|
||||||
|
// Sections whose model is description-plus-rules only (see extractGuidance).
|
||||||
|
const guidanceCoverage = (guidance) =>
|
||||||
|
guidance
|
||||||
|
? {
|
||||||
|
description: Boolean(guidance.description),
|
||||||
|
rules: guidance.rules.length,
|
||||||
|
}
|
||||||
|
: 'missing';
|
||||||
|
|
||||||
function assessCoverage(model) {
|
function assessCoverage(model) {
|
||||||
const report = {};
|
const report = {};
|
||||||
|
|
||||||
@@ -796,6 +873,8 @@ function assessCoverage(model) {
|
|||||||
}
|
}
|
||||||
: 'missing';
|
: 'missing';
|
||||||
|
|
||||||
|
report.layout = guidanceCoverage(model.layout);
|
||||||
|
|
||||||
report.elevation = model.elevation
|
report.elevation = model.elevation
|
||||||
? {
|
? {
|
||||||
shadows: model.elevation.shadows.length,
|
shadows: model.elevation.shadows.length,
|
||||||
@@ -804,6 +883,8 @@ function assessCoverage(model) {
|
|||||||
}
|
}
|
||||||
: 'missing';
|
: 'missing';
|
||||||
|
|
||||||
|
report.shapes = guidanceCoverage(model.shapes);
|
||||||
|
|
||||||
report.components = model.components
|
report.components = model.components
|
||||||
? {
|
? {
|
||||||
count: model.components.components.length,
|
count: model.components.components.length,
|
||||||
@@ -833,7 +914,9 @@ export function parseDesignMd(md) {
|
|||||||
overview: extractOverview(sections['Overview']),
|
overview: extractOverview(sections['Overview']),
|
||||||
colors: extractColors(sections['Colors']),
|
colors: extractColors(sections['Colors']),
|
||||||
typography: extractTypography(sections['Typography']),
|
typography: extractTypography(sections['Typography']),
|
||||||
|
layout: extractGuidance(sections['Layout']),
|
||||||
elevation: extractElevation(sections['Elevation']),
|
elevation: extractElevation(sections['Elevation']),
|
||||||
|
shapes: extractGuidance(sections['Shapes']),
|
||||||
components: extractComponents(sections['Components']),
|
components: extractComponents(sections['Components']),
|
||||||
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
|
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
export function browserOpenCommand(url, {
|
||||||
|
platform = process.platform,
|
||||||
|
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
|
||||||
|
} = {}) {
|
||||||
|
if (platform === 'darwin') return { command: 'open', args: [url] };
|
||||||
|
if (platform === 'win32') return { command: comspec, args: ['/c', 'start', '', url] };
|
||||||
|
return { command: 'xdg-open', args: [url] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSystemBrowser(url, {
|
||||||
|
platform = process.platform,
|
||||||
|
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
|
||||||
|
spawnImpl = spawn,
|
||||||
|
} = {}) {
|
||||||
|
const { command, args } = browserOpenCommand(url, { platform, comspec });
|
||||||
|
try {
|
||||||
|
const child = spawnImpl(command, args, { stdio: 'ignore', detached: true });
|
||||||
|
child.on('error', () => {});
|
||||||
|
child.unref();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,31 +96,38 @@ function* rank(items, input, idFor = item => item.id) {
|
|||||||
.map(entry => entry.item);
|
.map(entry => entry.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two independent exclusions, and either one is enough to hold a world back.
|
// Rating sets how many tickets a world holds; breadth decides whether it draws
|
||||||
// Rating grades quality: a 3-star earns a second ticket, a 1-star marginal keep
|
// at all. A niche world leaves the pool however good it is, keeping its approval
|
||||||
// leaves the pool. Breadth says whether a world can serve an arbitrary build at
|
// for direct briefs. Breadth was split out of rating because the only way to
|
||||||
// all, so a niche world leaves however good it is, keeping its approval for
|
// hold a narrow world back used to be calling it marginal, which made "excellent
|
||||||
// direct briefs. Breadth was split out of rating because the only way to hold a
|
// but narrow" unrecordable and corrupted ratings as a calibration signal.
|
||||||
// narrow world back used to be calling it marginal, which made "excellent but
|
//
|
||||||
// narrow" unrecordable and corrupted ratings as a calibration signal.
|
// Two tickets for a 3-star, one for everything else, was too sharp. Measured
|
||||||
|
// against the catalog as it stood: 3-star worlds absorbed 57% of the graphic
|
||||||
|
// draw from 65 of 163 eligible worlds, 46% of atmosphere from 13 of 43, and
|
||||||
|
// 75% of interaction from 15 of 25. The reviewer's complaint, that the same
|
||||||
|
// worlds keep coming back, is what a rating multiplier does to a pool whose
|
||||||
|
// thinnest tier holds 25 worlds.
|
||||||
|
//
|
||||||
|
// So a 3-star no longer outdraws a 2-star, and a 1-star draws at half rather
|
||||||
|
// than not at all. A marginal keep is still worth showing sometimes: the
|
||||||
|
// judgement it records is "narrow or unexceptional", not "wrong", and excluding
|
||||||
|
// it entirely made a rating do a job breadth already does properly.
|
||||||
|
const RATING_TICKETS = { 1: 1, 2: 2, 3: 2 };
|
||||||
|
const ticketsForRating = rating => RATING_TICKETS[rating] ?? 2;
|
||||||
|
|
||||||
function challengerTickets(pool) {
|
function challengerTickets(pool) {
|
||||||
return pool.flatMap(concept => {
|
return pool.flatMap(concept => {
|
||||||
const rating = concept.review?.rating;
|
if (concept.review?.breadth === 'niche') return [];
|
||||||
if (rating === 1 || concept.review?.breadth === 'niche') return [];
|
return Array.from({ length: ticketsForRating(concept.review?.rating) },
|
||||||
return rating === 3
|
(_, ticket) => ({ concept, ticket }));
|
||||||
? [{ concept, ticket: 0 }, { concept, ticket: 1 }]
|
|
||||||
: [{ concept, ticket: 0 }];
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function compositionTickets(pool) {
|
function compositionTickets(pool) {
|
||||||
return pool.flatMap(composition => {
|
return pool.flatMap(composition => Array.from(
|
||||||
const rating = composition.review?.rating;
|
{ length: ticketsForRating(composition.review?.rating) },
|
||||||
if (rating === 1) return [];
|
(_, ticket) => ({ composition, ticket })));
|
||||||
return rating === 3
|
|
||||||
? [{ composition, ticket: 0 }, { composition, ticket: 1 }]
|
|
||||||
: [{ composition, ticket: 0 }];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -117,6 +117,23 @@ export function checkDesignDrift({ designPath, projectRoot, threshold = 25 }) {
|
|||||||
* a section can be absent because it never applied, so this is reported as a
|
* a section can be absent because it never applied, so this is reported as a
|
||||||
* documentation gap for a human to judge, never as an error.
|
* documentation gap for a human to judge, never as an error.
|
||||||
*/
|
*/
|
||||||
|
function hasCoverageValue(value) {
|
||||||
|
if (Array.isArray(value)) return value.some(hasCoverageValue);
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.values(value).some(hasCoverageValue);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 && !/^(?:\[\s*\]|\{\s*\})$/.test(trimmed);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEED_DESIGN_MARKERS = ['/', '$'].map((prefix) =>
|
||||||
|
'<!-- SEED: established with the user before implementation; '
|
||||||
|
+ `re-run ${prefix}impeccable document once there's code to capture the actual tokens and components. -->`
|
||||||
|
);
|
||||||
|
|
||||||
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
||||||
if (!design || typeof parseDesignMd !== 'function') return [];
|
if (!design || typeof parseDesignMd !== 'function') return [];
|
||||||
let model;
|
let model;
|
||||||
@@ -125,8 +142,12 @@ export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
|||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const missing = ['colors', 'typography', 'components']
|
const isSeed = SEED_DESIGN_MARKERS.some((marker) => design.includes(marker));
|
||||||
.filter((section) => !model[section]);
|
const requiredSections = isSeed
|
||||||
|
? ['colors', 'typography']
|
||||||
|
: ['colors', 'typography', 'components'];
|
||||||
|
const missing = requiredSections
|
||||||
|
.filter((section) => !model[section] && !hasCoverageValue(model.frontmatter?.[section]));
|
||||||
if (!missing.length) return [];
|
if (!missing.length) return [];
|
||||||
return [finding({
|
return [finding({
|
||||||
id: 'design-md-coverage',
|
id: 'design-md-coverage',
|
||||||
|
|||||||
@@ -3766,7 +3766,10 @@
|
|||||||
const container = copyEditContainerContext(contextElement);
|
const container = copyEditContainerContext(contextElement);
|
||||||
if (container) for (const op of ops) op.container = container;
|
if (container) for (const op of ops) op.container = container;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', {
|
// Token in the query string as well as the body: the URL token is what
|
||||||
|
// authorizes the CORS preflight when the page runs on a non-loopback
|
||||||
|
// dev host (ddev, Valet), since the preflight carries no request body.
|
||||||
|
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -7150,7 +7153,10 @@
|
|||||||
console.debug('[impeccable] Dropped optional live event:', err);
|
console.debug('[impeccable] Dropped optional live event:', err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const doSend = () => fetch('http://localhost:' + PORT + '/events', {
|
// Token in the query string as well as the body: the URL token is what
|
||||||
|
// authorizes the CORS preflight when the page runs on a non-loopback
|
||||||
|
// dev host (ddev, Valet), since the preflight carries no request body.
|
||||||
|
const doSend = () => fetch('http://localhost:' + PORT + '/events?token=' + encodeURIComponent(TOKEN), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(msg),
|
body: JSON.stringify(msg),
|
||||||
@@ -11969,7 +11975,9 @@ void main() {
|
|||||||
rules: [
|
rules: [
|
||||||
...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
|
...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
|
||||||
...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
|
...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
|
||||||
|
...(md.layout?.rules || []).map((r) => ({ ...r, section: 'layout' })),
|
||||||
...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
|
...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
|
||||||
|
...(md.shapes?.rules || []).map((r) => ({ ...r, section: 'shapes' })),
|
||||||
],
|
],
|
||||||
dos: md.dosDonts?.dos || [],
|
dos: md.dosDonts?.dos || [],
|
||||||
donts: md.dosDonts?.donts || [],
|
donts: md.dosDonts?.donts || [],
|
||||||
|
|||||||
@@ -689,16 +689,24 @@ function isLoopbackOrigin(origin) {
|
|||||||
function createRequestHandler({ detectScript, liveScriptParts }) {
|
function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||||
return (req, res) => {
|
return (req, res) => {
|
||||||
const url = new URL(req.url, `http://localhost:${state.port}`);
|
const url = new URL(req.url, `http://localhost:${state.port}`);
|
||||||
// Loopback-restricted CORS. Reflect the caller's Origin only when it is a
|
// Token-or-loopback CORS. Reflect the caller's Origin when it is a
|
||||||
// loopback origin, always paired with `Vary: Origin` so an intermediary
|
// loopback origin OR the request carries the valid session token, always
|
||||||
// cache never serves a response authorized for one origin to another. A
|
// paired with `Vary: Origin` so an intermediary cache never serves a
|
||||||
// remote page (e.g. https://evil.example probing the port from a tab open
|
// response authorized for one origin to another. A remote page (e.g.
|
||||||
// on the same machine) gets no Access-Control-Allow-Origin, so its
|
// https://evil.example probing the port from a tab open on the same
|
||||||
// JS-initiated fetch cannot read any response. Requests with no Origin
|
// machine) has no token and gets no Access-Control-Allow-Origin, so its
|
||||||
// header (script tags, curl, the agent's own fetches) are not subject to
|
// JS-initiated fetch cannot read any response. The token branch exists for
|
||||||
// CORS and keep working; no ACAO header is needed for them.
|
// dev servers on non-localhost loopback aliases (ddev's *.ddev.site,
|
||||||
|
// Valet's *.test, hosts-file entries): the injected classic <script src>
|
||||||
|
// delivers the token to the page regardless of origin, every overlay
|
||||||
|
// request carries it in the query string (preflights included, since
|
||||||
|
// OPTIONS hits the same URL), and a token bearer is already fully
|
||||||
|
// authorized on every route — the token is the security boundary, not the
|
||||||
|
// origin. Requests with no Origin header (script tags, curl, the agent's
|
||||||
|
// own fetches) are not subject to CORS and keep working; no ACAO header
|
||||||
|
// is needed for them.
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (origin && isLoopbackOrigin(origin)) {
|
if (origin && (isLoopbackOrigin(origin) || url.searchParams.get('token') === state.token)) {
|
||||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
res.setHeader('Vary', 'Origin');
|
res.setHeader('Vary', 'Origin');
|
||||||
}
|
}
|
||||||
@@ -865,7 +873,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
|||||||
// { present, parsed, sidecar, hasMd, hasSidecar,
|
// { present, parsed, sidecar, hasMd, hasSidecar,
|
||||||
// mdNewerThanJson, parseError?, sidecarError? }
|
// mdNewerThanJson, parseError?, sidecarError? }
|
||||||
// - parsed: output of parseDesignMd (frontmatter
|
// - parsed: output of parseDesignMd (frontmatter
|
||||||
// + six canonical sections) when DESIGN.md exists.
|
// + the canonical sections) when DESIGN.md exists.
|
||||||
// - sidecar: .impeccable/design.json contents when present.
|
// - sidecar: .impeccable/design.json contents when present.
|
||||||
// Expected shape: schemaVersion 2, carrying
|
// Expected shape: schemaVersion 2, carrying
|
||||||
// extensions + components + narrative.
|
// extensions + components + narrative.
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* Framework-neutral Impeccable live chrome contract.
|
|
||||||
*
|
|
||||||
* The production browser bundle is intentionally plain DOM so Svelte, React,
|
|
||||||
* Vue, and static adapters can all mount the same chrome. This module is the
|
|
||||||
* testable contract/inventory for that bundle; live-browser.js mirrors these
|
|
||||||
* values at runtime because it is served as a standalone script.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
|
|
||||||
'root',
|
|
||||||
'transport',
|
|
||||||
'state',
|
|
||||||
'actions',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const LIVE_UI_SURFACES = Object.freeze([
|
|
||||||
{
|
|
||||||
key: 'global-bottom-bar',
|
|
||||||
ids: [
|
|
||||||
'impeccable-live-global-bar',
|
|
||||||
'impeccable-live-global-bar-brand',
|
|
||||||
'impeccable-live-pick-toggle',
|
|
||||||
'impeccable-live-insert-toggle',
|
|
||||||
'impeccable-live-detect-toggle',
|
|
||||||
'impeccable-live-detect-badge',
|
|
||||||
'impeccable-live-design-toggle',
|
|
||||||
'impeccable-live-page-chat',
|
|
||||||
'impeccable-live-page-chat-input',
|
|
||||||
'impeccable-live-page-chat-voice',
|
|
||||||
],
|
|
||||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'pending-copy-edit-dock',
|
|
||||||
ids: ['impeccable-live-pending-dock'],
|
|
||||||
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'element-selection-chrome',
|
|
||||||
ids: [
|
|
||||||
'impeccable-live-highlight',
|
|
||||||
'impeccable-live-tooltip',
|
|
||||||
'impeccable-live-bar',
|
|
||||||
'impeccable-live-selection-pill',
|
|
||||||
'impeccable-live-input',
|
|
||||||
'impeccable-live-configure-voice',
|
|
||||||
'impeccable-live-configure-bar-tooltip',
|
|
||||||
],
|
|
||||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'action-picker',
|
|
||||||
ids: ['impeccable-live-picker'],
|
|
||||||
states: ['closed', 'open', 'option-hover', 'option-focus'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'edit-chrome',
|
|
||||||
ids: ['impeccable-live-edit-badge'],
|
|
||||||
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'generating-row',
|
|
||||||
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
|
|
||||||
states: ['action-label', 'animated-dots', 'generating', 'done'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'variant-cycling-row',
|
|
||||||
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
|
|
||||||
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'variant-params-panel',
|
|
||||||
ids: ['impeccable-live-params-panel'],
|
|
||||||
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'saving-confirmed-rows',
|
|
||||||
ids: ['impeccable-live-bar'],
|
|
||||||
states: ['saving', 'applying-variant', 'confirmed'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'insert-mode-chrome',
|
|
||||||
ids: [
|
|
||||||
'impeccable-live-insert-line',
|
|
||||||
'impeccable-live-insert-placeholder',
|
|
||||||
'impeccable-live-placeholder-resize',
|
|
||||||
'impeccable-live-insert-input',
|
|
||||||
'impeccable-live-insert-voice',
|
|
||||||
'impeccable-live-insert-create',
|
|
||||||
'impeccable-live-insert-create-tooltip',
|
|
||||||
],
|
|
||||||
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'annotation-chrome',
|
|
||||||
ids: [
|
|
||||||
'impeccable-live-annot',
|
|
||||||
'impeccable-live-annot-svg',
|
|
||||||
'impeccable-live-annot-pins',
|
|
||||||
'impeccable-live-annot-clear',
|
|
||||||
],
|
|
||||||
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'design-system-panel',
|
|
||||||
ids: ['impeccable-live-design-host'],
|
|
||||||
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'toasts-and-errors',
|
|
||||||
ids: ['impeccable-live-toast'],
|
|
||||||
states: ['normal', 'error', 'no-variants-mounted'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'css-isolation-boundary',
|
|
||||||
ids: ['impeccable-live-root'],
|
|
||||||
states: ['shadow-root', 'style-tags', 'hostile-css'],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
|
|
||||||
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function resolveLiveUiRoot(env = globalThis) {
|
|
||||||
const doc = env?.document;
|
|
||||||
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|
|
||||||
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
|
|
||||||
if (explicit && typeof explicit.appendChild === 'function') return explicit;
|
|
||||||
return doc?.body || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLiveUiElementById(id, env = globalThis) {
|
|
||||||
const doc = env?.document;
|
|
||||||
const root = resolveLiveUiRoot(env);
|
|
||||||
if (!id) return null;
|
|
||||||
if (root?.getElementById) {
|
|
||||||
const found = root.getElementById(id);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
if (root?.querySelector) {
|
|
||||||
const found = root.querySelector('#' + escapeCssIdent(id));
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return doc?.getElementById?.(id) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendToLiveUiRoot(el, env = globalThis) {
|
|
||||||
const root = resolveLiveUiRoot(env);
|
|
||||||
if (!root) throw new Error('Impeccable live UI root is not available');
|
|
||||||
root.appendChild(el);
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
|
|
||||||
const doc = env?.document;
|
|
||||||
const root = resolveLiveUiRoot(env);
|
|
||||||
if (root && root !== doc?.body) {
|
|
||||||
root.appendChild(styleEl);
|
|
||||||
} else {
|
|
||||||
(doc?.head || doc?.body || root).appendChild(styleEl);
|
|
||||||
}
|
|
||||||
return styleEl;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activeElementDeep(doc = globalThis.document) {
|
|
||||||
let active = doc?.activeElement || null;
|
|
||||||
while (active?.shadowRoot?.activeElement) {
|
|
||||||
active = active.shadowRoot.activeElement;
|
|
||||||
}
|
|
||||||
return active;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeCssIdent(value) {
|
|
||||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
||||||
return CSS.escape(String(value));
|
|
||||||
}
|
|
||||||
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,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', '.github', '.grok',
|
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
|
||||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -93,15 +93,17 @@ function commandPrefixForSkillsDir(skillsDir) {
|
|||||||
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
|
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
function generatePinnedSkill(command, metadata, commandPrefix) {
|
function generatePinnedSkill(command, metadata, commandPrefix, isCodex) {
|
||||||
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
|
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
|
||||||
const hint = metadata[command]?.argumentHint || '[target]';
|
const hint = metadata[command]?.argumentHint || '[target]';
|
||||||
|
const providerFrontmatter = isCodex
|
||||||
|
? `metadata:\n argument-hint: "${hint}"`
|
||||||
|
: `argument-hint: "${hint}"\nuser-invocable: true`;
|
||||||
|
|
||||||
return `---
|
return `---
|
||||||
name: ${command}
|
name: ${command}
|
||||||
description: "${desc}"
|
description: "${desc}"
|
||||||
argument-hint: "${hint}"
|
${providerFrontmatter}
|
||||||
user-invocable: true
|
|
||||||
---
|
---
|
||||||
|
|
||||||
${PIN_MARKER}
|
${PIN_MARKER}
|
||||||
@@ -128,7 +130,7 @@ function pin(command, projectRoot) {
|
|||||||
|
|
||||||
for (const skillsDir of harnessDirs) {
|
for (const skillsDir of harnessDirs) {
|
||||||
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
|
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
|
||||||
const content = generatePinnedSkill(command, metadata, commandPrefix);
|
const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$');
|
||||||
// Check if skill already exists (and isn't a pin)
|
// Check if skill already exists (and isn't a pin)
|
||||||
const skillDir = join(skillsDir, command);
|
const skillDir = join(skillsDir, command);
|
||||||
if (existsSync(skillDir)) {
|
if (existsSync(skillDir)) {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { openSystemBrowser } from './lib/open-system-browser.mjs';
|
||||||
|
|
||||||
function arg(name, fallback = null) {
|
function arg(name, fallback = null) {
|
||||||
const i = process.argv.indexOf(`--${name}`);
|
const i = process.argv.indexOf(`--${name}`);
|
||||||
@@ -143,7 +144,7 @@ if (hasFlag('schema')) {
|
|||||||
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/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/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.', 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: '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: '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' },
|
||||||
],
|
],
|
||||||
reroll: true,
|
reroll: true,
|
||||||
@@ -151,7 +152,7 @@ if (hasFlag('schema')) {
|
|||||||
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.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/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. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
|
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. 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.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +322,11 @@ 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 hasBack = (option) => Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
|
const hasMedia = (option) => Boolean(option.sketchSrc || option.heroSrc || option.boardSrc);
|
||||||
|
// The back exists to keep long facts off a card whose front is an image;
|
||||||
|
// a card with no art has no flip chip to reach it, so it gets no back and
|
||||||
|
// the full read lives on the front instead.
|
||||||
|
const hasBack = (option) => hasMedia(option) && Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
|
||||||
const anatomy = (option) => {
|
const anatomy = (option) => {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
if (option.thesis) rows.push(`<p class="thesis">${esc(option.thesis)}</p>`);
|
if (option.thesis) rows.push(`<p class="thesis">${esc(option.thesis)}</p>`);
|
||||||
@@ -336,7 +341,15 @@ function page() {
|
|||||||
// 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 sketch lands, the first viewport is a picture.
|
||||||
rows.push(fact('Risk', option.risk, 'clamp'));
|
// With no art there is no back, so the full read fills the room the
|
||||||
|
// image would have taken.
|
||||||
|
if (hasMedia(option)) {
|
||||||
|
rows.push(fact('Risk', option.risk, 'clamp'));
|
||||||
|
} else {
|
||||||
|
rows.push(fact('First viewport', option.viewport));
|
||||||
|
rows.push(fact('The case', option.case));
|
||||||
|
rows.push(fact('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>`);
|
||||||
else if (option.body && option.thesis && !hasBack(option)) rows.push(`<p class="detail more">${esc(option.body)}</p>`);
|
else if (option.body && option.thesis && !hasBack(option)) rows.push(`<p class="detail more">${esc(option.body)}</p>`);
|
||||||
return rows.join('\n ');
|
return rows.join('\n ');
|
||||||
@@ -362,8 +375,11 @@ function page() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
if (option.heroSrc || option.boardSrc) {
|
if (option.heroSrc || option.boardSrc) {
|
||||||
return `<div class="media">
|
// Without a sketch the catalog art is the card's face; it stays a
|
||||||
|
// labeled reference so it never reads as the promise of the build.
|
||||||
|
return `<div class="media" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||||
|
<p class="media-label">inspiration</p>
|
||||||
<div class="chips">${expandChip}${details}</div>
|
<div class="chips">${expandChip}${details}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -547,6 +563,17 @@ function page() {
|
|||||||
.pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 3px 0 4px; background: oklch(7% 0.006 95 / 0.72); backdrop-filter: blur(3px); }
|
.pip 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); }
|
.sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||||
|
/* Catalog art standing in for a sketchless card is a reference, and says so
|
||||||
|
on its face; the same pill later carries "artwork unavailable". */
|
||||||
|
.media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); padding: 3px 8px 4px; background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 4px; backdrop-filter: blur(3px); }
|
||||||
|
/* Art that never arrives collapses to the card's own palette (painted
|
||||||
|
inline from its swatches) instead of sitting as a dark void wearing a
|
||||||
|
zoom cursor; the scrim keeps the label legible over saturated fields,
|
||||||
|
passes clicks through, and the flip chips stay above it. A card with no
|
||||||
|
palette falls back to the quiet graphite field. */
|
||||||
|
.media.unavailable { background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
|
||||||
|
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
|
||||||
|
.media.unavailable .chips { z-index: 2; }
|
||||||
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
||||||
the real sketch whenever it lands. */
|
the real sketch whenever it lands. */
|
||||||
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
||||||
@@ -709,6 +736,36 @@ function page() {
|
|||||||
tryLoad();
|
tryLoad();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A declared image that never loads (missing catalog asset, offline shell)
|
||||||
|
// must not sit as a dark void: the slot collapses to the card's own
|
||||||
|
// palette, labeled honestly, and the card competes on its facts. Sketch
|
||||||
|
// slots are excluded; their polling owns the wait.
|
||||||
|
const artFailed = (img) => {
|
||||||
|
const m = img.closest('.media');
|
||||||
|
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
|
||||||
|
m.classList.add('unavailable');
|
||||||
|
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
|
||||||
|
if (colors.length) m.style.background = 'linear-gradient(135deg, ' + colors.map((c, i) => c + ' ' + Math.round(i * 100 / colors.length) + '% ' + Math.round((i + 1) * 100 / colors.length) + '%').join(', ') + ')';
|
||||||
|
m.querySelector('.media-label')?.remove();
|
||||||
|
m.querySelector('.chip.expand')?.remove();
|
||||||
|
m.removeAttribute('title');
|
||||||
|
img.remove();
|
||||||
|
const label = document.createElement('p');
|
||||||
|
label.className = 'media-label';
|
||||||
|
label.textContent = 'artwork unavailable';
|
||||||
|
m.appendChild(label);
|
||||||
|
};
|
||||||
|
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
|
||||||
|
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
||||||
|
else img.addEventListener('error', () => artFailed(img), { once: true });
|
||||||
|
});
|
||||||
|
// A broken inspiration PIP just leaves; nothing depends on it.
|
||||||
|
document.querySelectorAll('.pip img').forEach(img => {
|
||||||
|
const gone = () => img.closest('.pip')?.remove();
|
||||||
|
if (img.complete && img.naturalWidth === 0) gone();
|
||||||
|
else img.addEventListener('error', gone, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Inspiration PIP opens the full catalog card in the lightbox.
|
// Inspiration PIP opens the full catalog card in the lightbox.
|
||||||
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
|
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -920,8 +977,7 @@ server.listen(portArg, '127.0.0.1', () => {
|
|||||||
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
|
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
|
||||||
}
|
}
|
||||||
if (!hasFlag('no-open')) {
|
if (!hasFlag('no-open')) {
|
||||||
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
openSystemBrowser(url);
|
||||||
try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ }
|
|
||||||
}
|
}
|
||||||
if (timeoutSec > 0) {
|
if (timeoutSec > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
|
|||||||
## 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
|
||||||
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
|
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.
|
||||||
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.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
|
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
|
||||||
"timeout": 5,
|
"timeout": 5,
|
||||||
"statusMessage": "Checking UI changes"
|
"statusMessage": "Checking UI changes"
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
|
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"statusMessage": "Design deep pass"
|
"statusMessage": "Design deep pass"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: impeccable
|
|||||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||||
version: 4.0.4
|
version: 4.0.4
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
|
argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
|
||||||
license: Apache 2.0
|
license: Apache 2.0
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Bash(npx impeccable *)
|
- Bash(npx impeccable *)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
|
|||||||
- **Type:** body measure 65–75ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
|
- **Type:** body measure 65–75ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
|
||||||
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
|
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
|
||||||
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
|
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
|
||||||
|
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
|
||||||
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
|
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
|
||||||
- **Coverage:** every brief requirement present and findable within seconds.
|
- **Coverage:** every brief requirement present and findable within seconds.
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
|
|||||||
## 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
|
||||||
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
|
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.
|
||||||
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.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ The script assigns which structure gets built; your top-ranked structure is what
|
|||||||
|
|
||||||
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, 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.
|
||||||
|
|
||||||
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, the canon card included. Serve the page first, then produce the sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
|
When image generation exists, every card also declares a `sketch` path under `.impeccable/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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -86,9 +86,12 @@ For `shape`, return the selected direction to [shape.md](shape.md) and stop befo
|
|||||||
|
|
||||||
## 6. Build with full commitment
|
## 6. Build with full commitment
|
||||||
|
|
||||||
|
When an approved comp exists, the comp is king, and the build happens in phases. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses.
|
||||||
|
|
||||||
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
|
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
|
||||||
|
|
||||||
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
|
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
|
||||||
|
- **Prove the hero before building past it.** When an approved comp exists, render the first viewport, capture it, and set it beside the comp's first viewport before any later section: the hero carries the run's ambition, and every following section inherits its shortfall. Judge scale and density as quantities, a field at a tenth of the comp's coverage or type at half its weight is a different design, and a five-minute retry here is what a rebuild verdict at the finish costs when this check is skipped.
|
||||||
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
|
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
|
||||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
|
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
|
||||||
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
|
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
|
||||||
@@ -102,6 +105,6 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
|||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. Where this harness runs no design hook, run `node .claude/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch: put that verdict in front of the user with the named comp regions and let them choose between a re-derivation and shipping as it stands. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
|
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. 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.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ After approval, record the choice where tools can find it: the approved comp's p
|
|||||||
|
|
||||||
## Inventory implementation fidelity
|
## Inventory implementation fidelity
|
||||||
|
|
||||||
Before building, inventory the approved comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same written inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
|
Before building, read the approved comp as a design system and record it in the brief: component grammar, corner language, line weights, elevation treatment, and the type ramp, because everything the comp does not show gets built from this record, and without it the fallback is the model's stock kit of square boxes, 1px grids, bento cells, and hard shadows. Then inventory the comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same written inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. The primary action gets its own row with its own medium: when the comp dissolves, stamps, erodes, or otherwise physically works the main CTA, that treatment is signature material on the page's most important element, and shrinking it to a border trick or a few decorative pixels is the compliance-token version of commitment. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
|
||||||
|
|
||||||
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack; writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish, is not a medium choice, it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly, diagrams with countable elements, controls, flat shape systems, and it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region instead of producing it is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity, so "no photography on hand" forbids fake proof, not an illustrated hero.
|
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack, and so is any texture by that name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient or layered background is not a texture medium and "layered CSS textures" is not a medium at all. Writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish or a cotton field's weave, is not a medium choice, it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly, diagrams with countable elements, controls, flat shape systems, and it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region instead of producing it is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity, so "no photography on hand" forbids fake proof, not an illustrated hero.
|
||||||
|
|
||||||
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond, and code executed safely and professionally remains first-class there. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
|
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond, and code executed safely and professionally remains first-class there. A field or texture built from many small elements carries a quantity commitment either way: write down its approximate density and coverage ("thousands of glyphs over two-thirds of the fold, dense at the top fading into the path"), because a field rebuilt at a tenth of its density passes every checklist and still is not the design. TYPE rows carry the same discipline: name the face's compression class, and render one headline word against the comp before building on it; a visibly wider or lighter silhouette means the face is wrong, and every section built on it inherits the miss. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
|
||||||
|
|
||||||
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies.
|
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies.
|
||||||
|
|
||||||
@@ -42,6 +42,8 @@ Treat the comp as a north star, not something to trace, and know what that allow
|
|||||||
|
|
||||||
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .claude/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
|
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .claude/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
|
||||||
|
|
||||||
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
|
When the harness runs subagents, spawn the shipped asset producer every time, even when the inventory's produce bucket looks empty: its manifest is the independent second opinion on your media, and runs that skipped the spawn are the runs whose cotton became CSS. An honestly empty manifest costs one cheap spawn; a wrongly empty produce bucket costs the build its materials. Use the producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
|
||||||
|
|
||||||
|
Convert images with a converter context.mjs reported at boot (the IMAGE_TOOLS line); probe only when it reported none, at most once per session, never per image.
|
||||||
|
|
||||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||||
|
|||||||
@@ -405,13 +405,18 @@ ASSIGNED INDEX: ${buildIndex}
|
|||||||
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.
|
||||||
No challengers this run: the roll service was unreachable and no local
|
No challengers this run: the roll service was unreachable and no local
|
||||||
catalog exists. A sandboxed exec tool with no network access causes exactly
|
catalog exists. A sandboxed shell without network egress is the most common
|
||||||
this; before accepting degradation, rerun this command once through the
|
cause; before accepting degradation, rerun this command once through the
|
||||||
harness's network-enabled command tool. A sandboxed shell without network egress is the most common
|
harness's network-enabled or escalated command tool. When that rerun needs
|
||||||
cause: if this harness can rerun the command with network access granted,
|
an approval, state exactly what the approver must know: this script's only
|
||||||
do that once before proceeding. Otherwise proceed with the grounded
|
network contact is one GET to https://impeccable.style/api/roll whose query
|
||||||
candidates alone; the assignment
|
carries scope, mode, an eight-hex seed key, and a re-roll counter; no
|
||||||
above still applies at full strength. Tell the user plainly that this roll
|
project files, prompts, code, or conversation context are transmitted, and
|
||||||
|
nothing is written. An approval request naming that URL and payload judges
|
||||||
|
the real action; a bare "run with network" invites rejection for contacting
|
||||||
|
an unspecified domain. If the rerun is still refused, proceed with the
|
||||||
|
grounded candidates alone; the assignment above still applies at full
|
||||||
|
strength. Tell the user plainly that this roll
|
||||||
ran degraded, with no challengers and no quality-bar boards; do not present
|
ran degraded, with no challengers and no quality-bar boards; do not present
|
||||||
the outcome as a full roll. A degraded roll changes the cards, not the
|
the outcome as a full roll. A degraded roll changes the cards, not the
|
||||||
channel: when a browser can open, present the direction on the decision page
|
channel: when a browser can open, present the direction on the decision page
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { loadContext, extractPlatform } from './context.mjs';
|
import { loadContext, extractPlatform } from './context.mjs';
|
||||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
import { readLatestSnapshotAcrossTargets } from './critique-storage.mjs';
|
||||||
|
|
||||||
/** Is there code here at all, or just context files / an empty repo? */
|
/** Is there code here at all, or just context files / an empty repo? */
|
||||||
function hasCode(cwd) {
|
function hasCode(cwd) {
|
||||||
@@ -34,23 +34,13 @@ function hasCode(cwd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The most recent critique snapshot across all targets. Filenames are
|
* Summarize the most recent critique snapshot across all targets.
|
||||||
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
|
|
||||||
* Parses the small frontmatter for score + P0/P1 counts.
|
|
||||||
*/
|
*/
|
||||||
function latestCritique(cwd) {
|
function latestCritique(cwd) {
|
||||||
try {
|
try {
|
||||||
const dir = getCritiqueDir(cwd);
|
const latest = readLatestSnapshotAcrossTargets({ cwd });
|
||||||
if (!fs.existsSync(dir)) return null;
|
if (!latest) return null;
|
||||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
|
const get = (key) => latest.meta[key] ?? null;
|
||||||
if (!files.length) return null;
|
|
||||||
const newest = files[files.length - 1];
|
|
||||||
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
|
|
||||||
const front = text.split('---')[1] || '';
|
|
||||||
const get = (k) => {
|
|
||||||
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
|
|
||||||
return m ? m[1].trim() : null;
|
|
||||||
};
|
|
||||||
const num = (v) => {
|
const num = (v) => {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
@@ -61,7 +51,7 @@ function latestCritique(cwd) {
|
|||||||
p0: num(get('p0')),
|
p0: num(get('p0')),
|
||||||
p1: num(get('p1')),
|
p1: num(get('p1')),
|
||||||
timestamp: get('timestamp'),
|
timestamp: get('timestamp'),
|
||||||
file: path.relative(cwd, path.join(dir, newest)),
|
file: path.relative(cwd, latest.path),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
* shape rather than the markdown block.
|
* shape rather than the markdown block.
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -1146,6 +1147,7 @@ async function cli() {
|
|||||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||||
parts.push(buildMissingTargetDirective());
|
parts.push(buildMissingTargetDirective());
|
||||||
}
|
}
|
||||||
|
appendImageToolsDirective(parts);
|
||||||
appendStalenessDirective(parts, ctx, cliOptions);
|
appendStalenessDirective(parts, ctx, cliOptions);
|
||||||
if (updateDirective) parts.push(updateDirective);
|
if (updateDirective) parts.push(updateDirective);
|
||||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||||
@@ -1180,6 +1182,7 @@ async function cli() {
|
|||||||
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
|
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
appendImageToolsDirective(parts);
|
||||||
appendStalenessDirective(parts, ctx, cliOptions);
|
appendStalenessDirective(parts, ctx, cliOptions);
|
||||||
if (!ctx.platform) {
|
if (!ctx.platform) {
|
||||||
// A `## Platform` section that names something we don't recognize (a
|
// A `## Platform` section that names something we don't recognize (a
|
||||||
@@ -1275,9 +1278,10 @@ function appendImageGenDirective(parts) {
|
|||||||
if (!process.env.OPENAI_API_KEY) return;
|
if (!process.env.OPENAI_API_KEY) return;
|
||||||
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
|
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
|
||||||
parts.push([
|
parts.push([
|
||||||
'IMAGE_GEN_AVAILABLE: An OpenAI key is present, so image generation works even without a harness-native image tool:',
|
'IMAGE_GEN_AVAILABLE: your harness-native image tool is always the first choice for generation; use it whenever one exists.',
|
||||||
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render).`,
|
'This environment also carries an OpenAI key as the fallback for harnesses with no native tool:',
|
||||||
'Prefer the harness-native image tool when one exists. Visualizing a direction before building it measurably strengthens the result.',
|
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render, and never reach for it when a native tool exists).`,
|
||||||
|
'Visualizing a direction before building it measurably strengthens the result.',
|
||||||
].join(' '));
|
].join(' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1332,6 +1336,19 @@ function appendDetectorFallback(parts, ctx) {
|
|||||||
// markdown already in memory, a bounded set of stats, or one of the small JSON
|
// markdown already in memory, a bounded set of stats, or one of the small JSON
|
||||||
// files the boot reads regardless. The deep pass (git drift, token divergence,
|
// files the boot reads regardless. The deep pass (git drift, token divergence,
|
||||||
// cross-workspace sweep) belongs to the doctor command, not to every session.
|
// cross-workspace sweep) belongs to the doctor command, not to every session.
|
||||||
|
// One boot-time probe replaces every session re-deriving its image toolchain:
|
||||||
|
// harnesses and OSes differ (cwebp, sips on macOS, magick, ffmpeg), and the
|
||||||
|
// agent should read this line instead of running command -v per image.
|
||||||
|
function appendImageToolsDirective(parts) {
|
||||||
|
const probe = process.platform === 'win32' ? 'where' : 'which';
|
||||||
|
const found = ['cwebp', 'sips', 'magick', 'ffmpeg'].filter((tool) => {
|
||||||
|
try { return spawnSync(probe, [tool], { stdio: 'ignore' }).status === 0; } catch { return false; }
|
||||||
|
});
|
||||||
|
parts.push(found.length
|
||||||
|
? `IMAGE_TOOLS: available image converters on this machine: ${found.join(', ')}. Use the first suitable one; never probe again this session.`
|
||||||
|
: 'IMAGE_TOOLS: no image converter found (cwebp, sips, magick, ffmpeg). Ship PNG output unconverted rather than probing per image.');
|
||||||
|
}
|
||||||
|
|
||||||
function appendStalenessDirective(parts, ctx, options) {
|
function appendStalenessDirective(parts, ctx, options) {
|
||||||
const projectRoot = ctx.projectRoot || process.cwd();
|
const projectRoot = ctx.projectRoot || process.cwd();
|
||||||
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
|
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
|
||||||
|
|||||||
@@ -105,28 +105,37 @@ function parseFrontmatter(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return all snapshot files for `slug`, sorted oldest → newest.
|
* Return snapshot files matching `suffix`, sorted oldest → newest.
|
||||||
*/
|
*/
|
||||||
function listSnapshotsForSlug(slug, cwd) {
|
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
|
||||||
|
|
||||||
|
function listSnapshots(suffix, cwd) {
|
||||||
const dir = getCritiqueDir(cwd);
|
const dir = getCritiqueDir(cwd);
|
||||||
if (!fs.existsSync(dir)) return [];
|
if (!fs.existsSync(dir)) return [];
|
||||||
const suffix = `__${slug}.md`;
|
|
||||||
return fs.readdirSync(dir)
|
return fs.readdirSync(dir)
|
||||||
.filter((f) => f.endsWith(suffix))
|
.filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
|
||||||
.sort()
|
.sort()
|
||||||
.map((f) => path.join(dir, f));
|
.map((f) => path.join(dir, f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readLatestSnapshotMatching(suffix, cwd) {
|
||||||
|
const filePath = listSnapshots(suffix, cwd).at(-1);
|
||||||
|
if (!filePath) return null;
|
||||||
|
const body = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return { path: filePath, body, meta: parseFrontmatter(body) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
||||||
* to find its fix backlog when the slug matches.
|
* to find its fix backlog when the slug matches.
|
||||||
*/
|
*/
|
||||||
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||||
const all = listSnapshotsForSlug(slug, cwd);
|
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
|
||||||
if (!all.length) return null;
|
}
|
||||||
const latest = all[all.length - 1];
|
|
||||||
const body = fs.readFileSync(latest, 'utf-8');
|
/** Return the most recent snapshot across all targets, or null. */
|
||||||
return { path: latest, body, meta: parseFrontmatter(body) };
|
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
|
||||||
|
return readLatestSnapshotMatching('.md', cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,7 +143,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
|||||||
* Critique appends a one-line trend to its output using this.
|
* Critique appends a one-line trend to its output using this.
|
||||||
*/
|
*/
|
||||||
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
||||||
const all = listSnapshotsForSlug(slug, cwd);
|
const all = listSnapshots(`__${slug}.md`, cwd);
|
||||||
const slice = all.slice(-limit);
|
const slice = all.slice(-limit);
|
||||||
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
|
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,10 +142,73 @@ function stripInlineYamlComment(s) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YAML double-quoted scalars process backslash escapes. Stripping the outer
|
||||||
|
// quotes without unescaping leaves them in place, so a nested font family like
|
||||||
|
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
|
||||||
|
// reaches allowedFonts as '\"ibm plex sans' and never matches the same family
|
||||||
|
// declared in CSS. Scanner instead of a regex: the escape set is small and the
|
||||||
|
// backslash handling stays readable.
|
||||||
|
// The full YAML 1.2 double-quote escape set (spec section 5.7).
|
||||||
|
const YAML_SIMPLE_ESCAPES = {
|
||||||
|
'0': '\0',
|
||||||
|
a: '\x07',
|
||||||
|
b: '\b',
|
||||||
|
t: '\t',
|
||||||
|
n: '\n',
|
||||||
|
v: '\v',
|
||||||
|
f: '\f',
|
||||||
|
r: '\r',
|
||||||
|
e: '\x1b',
|
||||||
|
' ': ' ',
|
||||||
|
'"': '"',
|
||||||
|
'/': '/',
|
||||||
|
'\\': '\\',
|
||||||
|
N: '\u0085',
|
||||||
|
_: '\u00a0',
|
||||||
|
L: '\u2028',
|
||||||
|
P: '\u2029',
|
||||||
|
};
|
||||||
|
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
|
||||||
|
|
||||||
|
function unescapeYamlDoubleQuoted(body) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
const ch = body[i];
|
||||||
|
if (ch !== '\\' || i === body.length - 1) {
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = body[i + 1];
|
||||||
|
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
|
||||||
|
out += YAML_SIMPLE_ESCAPES[next];
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
|
||||||
|
// literal rather than corrupting the rest of the scalar.
|
||||||
|
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
|
||||||
|
if (hexLen) {
|
||||||
|
const hex = body.slice(i + 2, i + 2 + hexLen);
|
||||||
|
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
|
||||||
|
if (codePoint >= 0 && codePoint <= 0x10ffff) {
|
||||||
|
out += String.fromCodePoint(codePoint);
|
||||||
|
i += 1 + hexLen;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function parseScalar(raw) {
|
function parseScalar(raw) {
|
||||||
const s = raw.trim();
|
const s = raw.trim();
|
||||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||||
return s.slice(1, -1);
|
return unescapeYamlDoubleQuoted(s.slice(1, -1));
|
||||||
|
}
|
||||||
|
// Single-quoted YAML escapes only the quote itself, by doubling it.
|
||||||
|
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
|
||||||
|
return s.slice(1, -1).split("''").join("'");
|
||||||
}
|
}
|
||||||
if (s === 'true') return true;
|
if (s === 'true') return true;
|
||||||
if (s === 'false') return false;
|
if (s === 'false') return false;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.m
|
|||||||
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
||||||
export {
|
export {
|
||||||
walkDir,
|
walkDir,
|
||||||
|
hasScannableExtension,
|
||||||
SCANNABLE_EXTENSIONS,
|
SCANNABLE_EXTENSIONS,
|
||||||
SKIP_DIRS,
|
SKIP_DIRS,
|
||||||
buildImportGraph,
|
buildImportGraph,
|
||||||
|
|||||||
@@ -41,6 +41,221 @@ function shouldRunPageAnalyzers(content, filePath) {
|
|||||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
||||||
|
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
|
||||||
|
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
|
||||||
|
|
||||||
|
function isInsideOpeningJsxTag(source) {
|
||||||
|
const tagStart = source.lastIndexOf('<');
|
||||||
|
if (tagStart === -1 || !/^<[A-Za-z][\w.:-]*/.test(source.slice(tagStart))) return false;
|
||||||
|
|
||||||
|
let quote = '';
|
||||||
|
for (let cursor = tagStart + 1; cursor < source.length; cursor++) {
|
||||||
|
const char = source[cursor];
|
||||||
|
if (quote) {
|
||||||
|
if (char === '\\') cursor++;
|
||||||
|
else if (char === quote) quote = '';
|
||||||
|
} else if (char === "'" || char === '"') {
|
||||||
|
quote = char;
|
||||||
|
} else if (char === '>') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blank JavaScript comments without moving any following source. Regex
|
||||||
|
* findings keep their original line numbers, while prose examples inside
|
||||||
|
* comments cannot masquerade as rendered markup.
|
||||||
|
*/
|
||||||
|
function stripJsComments(content, options = {}) {
|
||||||
|
let state = 'code';
|
||||||
|
let output = '';
|
||||||
|
let lastSignificant = '';
|
||||||
|
let previousSignificant = '';
|
||||||
|
let antePreviousSignificant = '';
|
||||||
|
let currentWord = '';
|
||||||
|
let currentWordPrefix = '';
|
||||||
|
let wordSeparated = false;
|
||||||
|
let regexCharClass = false;
|
||||||
|
let jsxExpressionDepth = 0;
|
||||||
|
let lastClosedBraceKind = '';
|
||||||
|
const braceKinds = [];
|
||||||
|
const templateExpressionDepths = [];
|
||||||
|
|
||||||
|
const braceKind = (startsJsxExpression = false) => (
|
||||||
|
!startsJsxExpression && (
|
||||||
|
!lastSignificant ||
|
||||||
|
lastSignificant === ')' ||
|
||||||
|
lastSignificant === ';' ||
|
||||||
|
lastSignificant === '}' ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
||||||
|
) ? 'block' : 'expression'
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordSignificant = (char) => {
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
wordSeparated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isWordChar = /[\w$]/.test(char);
|
||||||
|
if (isWordChar && (wordSeparated || !currentWord)) {
|
||||||
|
currentWord = '';
|
||||||
|
currentWordPrefix = lastSignificant;
|
||||||
|
} else if (!isWordChar) {
|
||||||
|
currentWordPrefix = '';
|
||||||
|
}
|
||||||
|
wordSeparated = false;
|
||||||
|
antePreviousSignificant = previousSignificant;
|
||||||
|
previousSignificant = lastSignificant;
|
||||||
|
lastSignificant = char;
|
||||||
|
currentWord = isWordChar ? currentWord + char : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
const char = content[i];
|
||||||
|
const next = content[i + 1];
|
||||||
|
|
||||||
|
if (state === 'line-comment') {
|
||||||
|
if (char === '\n') {
|
||||||
|
output += char;
|
||||||
|
state = 'code';
|
||||||
|
} else {
|
||||||
|
output += ' ';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'block-comment') {
|
||||||
|
if (char === '*' && next === '/') {
|
||||||
|
output += ' ';
|
||||||
|
i++;
|
||||||
|
state = 'code';
|
||||||
|
} else {
|
||||||
|
output += char === '\n' ? '\n' : ' ';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'regex') {
|
||||||
|
output += char;
|
||||||
|
if (char === '\\' && next) {
|
||||||
|
output += next;
|
||||||
|
i++;
|
||||||
|
} else if (char === '[') {
|
||||||
|
regexCharClass = true;
|
||||||
|
} else if (char === ']') {
|
||||||
|
regexCharClass = false;
|
||||||
|
} else if (char === '/' && !regexCharClass) {
|
||||||
|
state = 'code';
|
||||||
|
recordSignificant('/');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'template' && char === '$' && next === '{') {
|
||||||
|
output += '${';
|
||||||
|
i++;
|
||||||
|
recordSignificant('$');
|
||||||
|
recordSignificant('{');
|
||||||
|
templateExpressionDepths.push(1);
|
||||||
|
braceKinds.push('expression');
|
||||||
|
if (jsxExpressionDepth) jsxExpressionDepth++;
|
||||||
|
state = 'code';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== 'code') {
|
||||||
|
output += char;
|
||||||
|
if (char === '\\' && next) {
|
||||||
|
output += next;
|
||||||
|
i++;
|
||||||
|
} else if (
|
||||||
|
(state === 'single-quote' && char === "'") ||
|
||||||
|
(state === 'double-quote' && char === '"') ||
|
||||||
|
(state === 'template' && char === '`')
|
||||||
|
) {
|
||||||
|
state = 'code';
|
||||||
|
recordSignificant(char);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsxUrlSeparator = options.jsx && char === '/' && next === '/' &&
|
||||||
|
jsxExpressionDepth === 0 &&
|
||||||
|
(output.endsWith('http:') ||
|
||||||
|
output.endsWith('https:') ||
|
||||||
|
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1)) &&
|
||||||
|
/^[\w.-]+\.[A-Za-z]{2,}(?=[:/?#\s<]|$)/.test(content.slice(i + 2))));
|
||||||
|
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
||||||
|
previousSignificant === lastSignificant &&
|
||||||
|
antePreviousSignificant !== lastSignificant;
|
||||||
|
if (char === '/' && next === '/' && jsxUrlSeparator) {
|
||||||
|
output += '//';
|
||||||
|
i++;
|
||||||
|
recordSignificant('/');
|
||||||
|
recordSignificant('/');
|
||||||
|
} else if (char === '/' && next === '/') {
|
||||||
|
output += ' ';
|
||||||
|
i++;
|
||||||
|
state = 'line-comment';
|
||||||
|
} else if (char === '/' && next === '*') {
|
||||||
|
output += ' ';
|
||||||
|
i++;
|
||||||
|
state = 'block-comment';
|
||||||
|
} else if (templateExpressionDepths.length && char === '{') {
|
||||||
|
output += char;
|
||||||
|
templateExpressionDepths[templateExpressionDepths.length - 1]++;
|
||||||
|
braceKinds.push(braceKind());
|
||||||
|
if (jsxExpressionDepth) jsxExpressionDepth++;
|
||||||
|
recordSignificant(char);
|
||||||
|
} else if (templateExpressionDepths.length && char === '}') {
|
||||||
|
output += char;
|
||||||
|
const depthIndex = templateExpressionDepths.length - 1;
|
||||||
|
templateExpressionDepths[depthIndex]--;
|
||||||
|
lastClosedBraceKind = braceKinds.pop() || '';
|
||||||
|
if (jsxExpressionDepth) jsxExpressionDepth--;
|
||||||
|
recordSignificant(char);
|
||||||
|
if (templateExpressionDepths[depthIndex] === 0) {
|
||||||
|
templateExpressionDepths.pop();
|
||||||
|
state = 'template';
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
char === '/' &&
|
||||||
|
(!lastSignificant ||
|
||||||
|
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
||||||
|
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
||||||
|
) {
|
||||||
|
output += char;
|
||||||
|
state = 'regex';
|
||||||
|
regexCharClass = false;
|
||||||
|
} else {
|
||||||
|
output += char;
|
||||||
|
const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 &&
|
||||||
|
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1, -1)) ||
|
||||||
|
isInsideOpeningJsxTag(output.slice(0, -1)));
|
||||||
|
if (char === '{') braceKinds.push(braceKind(startsJsxExpression));
|
||||||
|
else if (char === '}') lastClosedBraceKind = braceKinds.pop() || '';
|
||||||
|
if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth++;
|
||||||
|
else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth--;
|
||||||
|
recordSignificant(char);
|
||||||
|
if (char === "'") state = 'single-quote';
|
||||||
|
else if (char === '"') state = 'double-quote';
|
||||||
|
else if (char === '`') state = 'template';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCssComments(content) {
|
||||||
|
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
|
||||||
|
}
|
||||||
|
|
||||||
function firstOverusedGoogleFont(text) {
|
function firstOverusedGoogleFont(text) {
|
||||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||||
}
|
}
|
||||||
@@ -528,18 +743,198 @@ function extractStyleBlocks(content, ext) {
|
|||||||
|
|
||||||
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
||||||
|
|
||||||
|
function findQuotedStringEnd(content, start, quote) {
|
||||||
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||||
|
if (content[cursor] === '\\') cursor++;
|
||||||
|
else if (content[cursor] === quote) return cursor;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRegexLiteralEnd(content, start) {
|
||||||
|
let inCharacterClass = false;
|
||||||
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||||
|
const char = content[cursor];
|
||||||
|
if (char === '\\') {
|
||||||
|
cursor++;
|
||||||
|
} else if (char === '[') {
|
||||||
|
inCharacterClass = true;
|
||||||
|
} else if (char === ']') {
|
||||||
|
inCharacterClass = false;
|
||||||
|
} else if (char === '/' && !inCharacterClass) {
|
||||||
|
while (/[A-Za-z]/.test(content[cursor + 1] || '')) cursor++;
|
||||||
|
return cursor;
|
||||||
|
} else if (char === '\n' || char === '\r') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTemplateExpressionEnd(content, start) {
|
||||||
|
let depth = 1;
|
||||||
|
let lastSignificant = '';
|
||||||
|
let previousSignificant = '';
|
||||||
|
let antePreviousSignificant = '';
|
||||||
|
let currentWord = '';
|
||||||
|
let currentWordPrefix = '';
|
||||||
|
let wordSeparated = false;
|
||||||
|
let lastClosedBraceKind = '';
|
||||||
|
const braceKinds = [];
|
||||||
|
|
||||||
|
const braceKind = () => (
|
||||||
|
lastSignificant === ')' ||
|
||||||
|
lastSignificant === ';' ||
|
||||||
|
lastSignificant === '}' ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
||||||
|
? 'block'
|
||||||
|
: 'expression'
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordSignificant = (char) => {
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
wordSeparated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isWordChar = /[\w$]/.test(char);
|
||||||
|
if (isWordChar && (wordSeparated || !currentWord)) {
|
||||||
|
currentWord = '';
|
||||||
|
currentWordPrefix = lastSignificant;
|
||||||
|
} else if (!isWordChar) {
|
||||||
|
currentWordPrefix = '';
|
||||||
|
}
|
||||||
|
wordSeparated = false;
|
||||||
|
antePreviousSignificant = previousSignificant;
|
||||||
|
previousSignificant = lastSignificant;
|
||||||
|
lastSignificant = char;
|
||||||
|
currentWord = isWordChar ? currentWord + char : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let cursor = start; cursor < content.length; cursor++) {
|
||||||
|
const char = content[cursor];
|
||||||
|
const next = content[cursor + 1];
|
||||||
|
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
||||||
|
previousSignificant === lastSignificant &&
|
||||||
|
antePreviousSignificant !== lastSignificant;
|
||||||
|
if (char === "'" || char === '"') {
|
||||||
|
cursor = findQuotedStringEnd(content, cursor, char);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
recordSignificant(')');
|
||||||
|
} else if (char === '/' && next === '/') {
|
||||||
|
const lineEnd = content.indexOf('\n', cursor + 2);
|
||||||
|
if (lineEnd === -1) return -1;
|
||||||
|
cursor = lineEnd;
|
||||||
|
} else if (char === '/' && next === '*') {
|
||||||
|
const commentEnd = content.indexOf('*/', cursor + 2);
|
||||||
|
if (commentEnd === -1) return -1;
|
||||||
|
cursor = commentEnd + 1;
|
||||||
|
} else if (
|
||||||
|
char === '/' &&
|
||||||
|
(!lastSignificant ||
|
||||||
|
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
||||||
|
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
||||||
|
) {
|
||||||
|
cursor = findRegexLiteralEnd(content, cursor);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
recordSignificant(')');
|
||||||
|
} else if (char === '`') {
|
||||||
|
cursor = findTemplateLiteralEnd(content, cursor);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
recordSignificant(')');
|
||||||
|
} else if (char === '{') {
|
||||||
|
depth++;
|
||||||
|
braceKinds.push(braceKind());
|
||||||
|
recordSignificant(char);
|
||||||
|
} else if (char === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return cursor;
|
||||||
|
lastClosedBraceKind = braceKinds.pop() || '';
|
||||||
|
recordSignificant(char);
|
||||||
|
} else {
|
||||||
|
recordSignificant(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTemplateLiteralEnd(content, start) {
|
||||||
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||||
|
const char = content[cursor];
|
||||||
|
if (char === '\\') {
|
||||||
|
cursor++;
|
||||||
|
} else if (char === '`') {
|
||||||
|
return cursor;
|
||||||
|
} else if (char === '$' && content[cursor + 1] === '{') {
|
||||||
|
cursor = findTemplateExpressionEnd(content, cursor + 2);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCSSinJSTemplates(content) {
|
||||||
|
const templates = [];
|
||||||
|
const tagRe = /\b(?:styled(?:\.\w+|\([^)]+\))|css)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = tagRe.exec(content)) !== null) {
|
||||||
|
let cursor = match.index + match[0].length;
|
||||||
|
while (/\s/.test(content[cursor] || '')) cursor++;
|
||||||
|
|
||||||
|
if (content[cursor] === '<') {
|
||||||
|
let depth = 0;
|
||||||
|
while (cursor < content.length) {
|
||||||
|
const char = content[cursor];
|
||||||
|
if (char === '<') depth++;
|
||||||
|
else if (char === '>' && content[cursor - 1] !== '=') depth--;
|
||||||
|
cursor++;
|
||||||
|
if (depth === 0) break;
|
||||||
|
}
|
||||||
|
if (depth !== 0) continue;
|
||||||
|
while (/\s/.test(content[cursor] || '')) cursor++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content[cursor] !== '`') continue;
|
||||||
|
const contentStart = cursor + 1;
|
||||||
|
cursor = findTemplateLiteralEnd(content, cursor);
|
||||||
|
if (cursor === -1) continue;
|
||||||
|
|
||||||
|
templates.push({
|
||||||
|
tagStart: match.index,
|
||||||
|
contentStart,
|
||||||
|
contentEnd: cursor,
|
||||||
|
});
|
||||||
|
tagRe.lastIndex = cursor + 1;
|
||||||
|
}
|
||||||
|
return templates;
|
||||||
|
}
|
||||||
|
|
||||||
function extractCSSinJS(content, ext) {
|
function extractCSSinJS(content, ext) {
|
||||||
ext = ext.toLowerCase();
|
ext = ext.toLowerCase();
|
||||||
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
||||||
const blocks = [];
|
return findCSSinJSTemplates(content).map((template) => {
|
||||||
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
|
const before = content.substring(0, template.tagStart);
|
||||||
let m;
|
|
||||||
while ((m = re.exec(content)) !== null) {
|
|
||||||
const before = content.substring(0, m.index);
|
|
||||||
const startLine = before.split('\n').length;
|
const startLine = before.split('\n').length;
|
||||||
blocks.push({ content: m[1], startLine });
|
return {
|
||||||
|
content: content.slice(template.contentStart, template.contentEnd),
|
||||||
|
startLine,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCssInJsComments(content, ext) {
|
||||||
|
if (!CSS_IN_JS_EXTENSIONS.has(ext.toLowerCase())) return content;
|
||||||
|
const templates = findCSSinJSTemplates(content);
|
||||||
|
let output = '';
|
||||||
|
let cursor = 0;
|
||||||
|
for (const template of templates) {
|
||||||
|
output += content.slice(cursor, template.contentStart);
|
||||||
|
output += stripCssComments(content.slice(template.contentStart, template.contentEnd));
|
||||||
|
cursor = template.contentEnd;
|
||||||
}
|
}
|
||||||
return blocks;
|
return output + content.slice(cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
|
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
|
||||||
@@ -627,8 +1022,12 @@ function runTextContentAnalyzers(content, filePath, options = {}) {
|
|||||||
function detectText(content, filePath, options = {}) {
|
function detectText(content, filePath, options = {}) {
|
||||||
const profile = options?.profile;
|
const profile = options?.profile;
|
||||||
const findings = [];
|
const findings = [];
|
||||||
const lines = content.split('\n');
|
|
||||||
const ext = extFromFilePath(filePath);
|
const ext = extFromFilePath(filePath);
|
||||||
|
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
|
||||||
|
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
|
||||||
|
}) : content;
|
||||||
|
const source = stripCssInJsComments(commentStrippedSource, ext);
|
||||||
|
const lines = source.split('\n');
|
||||||
|
|
||||||
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
||||||
// Enable block context for CSS files where related properties span multiple lines
|
// Enable block context for CSS files where related properties span multiple lines
|
||||||
@@ -661,8 +1060,8 @@ function detectText(content, filePath, options = {}) {
|
|||||||
phase: 'source',
|
phase: 'source',
|
||||||
ruleId: 'codex-grid-background',
|
ruleId: 'codex-grid-background',
|
||||||
target: filePath,
|
target: filePath,
|
||||||
}, () => scanCssTextForGridBackground(content).map(hit => {
|
}, () => scanCssTextForGridBackground(source).map(hit => {
|
||||||
const line = content.substring(0, hit.index).split('\n').length;
|
const line = source.substring(0, hit.index).split('\n').length;
|
||||||
return finding('codex-grid-background', filePath, hit.snippet, line);
|
return finding('codex-grid-background', filePath, hit.snippet, line);
|
||||||
})));
|
})));
|
||||||
|
|
||||||
@@ -698,16 +1097,17 @@ function detectText(content, filePath, options = {}) {
|
|||||||
phase: 'extract',
|
phase: 'extract',
|
||||||
ruleId: 'css-in-js',
|
ruleId: 'css-in-js',
|
||||||
target: filePath,
|
target: filePath,
|
||||||
}, () => extractCSSinJS(content, ext))
|
}, () => extractCSSinJS(source, ext))
|
||||||
: extractCSSinJS(content, ext);
|
: extractCSSinJS(source, ext);
|
||||||
for (const block of cssJsBlocks) {
|
for (const block of cssJsBlocks) {
|
||||||
const blockLines = block.content.split('\n');
|
const blockContent = stripCssComments(block.content);
|
||||||
|
const blockLines = blockContent.split('\n');
|
||||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||||
profile,
|
profile,
|
||||||
phase: 'css-in-js',
|
phase: 'css-in-js',
|
||||||
}));
|
}));
|
||||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
|
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 1));
|
||||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
|
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.designSystem) {
|
if (options?.designSystem) {
|
||||||
|
|||||||
@@ -26,11 +26,26 @@ const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
|
|||||||
const SCANNABLE_EXTENSIONS = new Set([
|
const SCANNABLE_EXTENSIONS = new Set([
|
||||||
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
||||||
'.jsx', '.tsx', '.js', '.ts',
|
'.jsx', '.tsx', '.js', '.ts',
|
||||||
'.vue', '.svelte', '.astro',
|
'.vue', '.svelte', '.astro', '.blade.php',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
||||||
|
|
||||||
|
function hasScannableExtension(filename) {
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
|
||||||
|
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||||
|
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORT_SPECIFIER_PATTERNS = [
|
||||||
|
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
|
||||||
|
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
|
||||||
|
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||||
|
];
|
||||||
|
|
||||||
function walkDir(dir) {
|
function walkDir(dir) {
|
||||||
const files = [];
|
const files = [];
|
||||||
let entries;
|
let entries;
|
||||||
@@ -40,7 +55,7 @@ function walkDir(dir) {
|
|||||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||||
const full = path.join(dir, entry.name);
|
const full = path.join(dir, entry.name);
|
||||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||||
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
|
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
@@ -75,26 +90,11 @@ function buildImportGraph(files) {
|
|||||||
const dir = path.dirname(file);
|
const dir = path.dirname(file);
|
||||||
const imports = new Set();
|
const imports = new Set();
|
||||||
|
|
||||||
// ES imports: import ... from '...' and import '...'
|
for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
|
||||||
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
|
for (const match of content.matchAll(pattern)) {
|
||||||
let m;
|
const resolved = resolveImport(match[1], dir, fileSet);
|
||||||
while ((m = esRe.exec(content)) !== null) {
|
if (resolved) imports.add(resolved);
|
||||||
const resolved = resolveImport(m[1], dir, fileSet);
|
}
|
||||||
if (resolved) imports.add(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSS @import
|
|
||||||
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
|
|
||||||
while ((m = cssRe.exec(content)) !== null) {
|
|
||||||
const resolved = resolveImport(m[1], dir, fileSet);
|
|
||||||
if (resolved) imports.add(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SCSS @use / @forward
|
|
||||||
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
|
|
||||||
while ((m = scssRe.exec(content)) !== null) {
|
|
||||||
const resolved = resolveImport(m[1], dir, fileSet);
|
|
||||||
if (resolved) imports.add(resolved);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
graph.set(file, imports);
|
graph.set(file, imports);
|
||||||
@@ -203,6 +203,7 @@ export {
|
|||||||
SKIP_DIRS,
|
SKIP_DIRS,
|
||||||
SCANNABLE_EXTENSIONS,
|
SCANNABLE_EXTENSIONS,
|
||||||
HTML_EXTENSIONS,
|
HTML_EXTENSIONS,
|
||||||
|
hasScannableExtension,
|
||||||
walkDir,
|
walkDir,
|
||||||
resolveImport,
|
resolveImport,
|
||||||
buildImportGraph,
|
buildImportGraph,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* node hook-admin.mjs off # set enabled: false
|
* node hook-admin.mjs off # set enabled: false
|
||||||
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
|
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
|
||||||
* node hook-admin.mjs ignore-rule overused-font --all-values
|
* node hook-admin.mjs ignore-rule overused-font --all-values
|
||||||
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
|
* node hook-admin.mjs ignore-file <glob> [--shared|--local] # append to ignoreFiles
|
||||||
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
||||||
* node hook-admin.mjs ignore-value <rule> <value> --local
|
* node hook-admin.mjs ignore-value <rule> <value> --local
|
||||||
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
|
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
|
||||||
@@ -166,7 +166,7 @@ function readRawConfigFile(filePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
|
||||||
|
|
||||||
function hookSection(unified) {
|
function hookSection(unified) {
|
||||||
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
|
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
|
||||||
@@ -200,6 +200,15 @@ function stripDetectorKeys(raw) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickDetectorKeys(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||||
|
const out = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Write hook runtime config under `hook`, leaving detector filters in
|
// Write hook runtime config under `hook`, leaving detector filters in
|
||||||
// `detector` and preserving sibling keys such as updateCheck.
|
// `detector` and preserving sibling keys such as updateCheck.
|
||||||
function writeHookConfig(cwd, hookConfig, opts = {}) {
|
function writeHookConfig(cwd, hookConfig, opts = {}) {
|
||||||
@@ -207,10 +216,19 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
|
|||||||
if (opts.local) ensureHookGitExcludes(cwd);
|
if (opts.local) ensureHookGitExcludes(cwd);
|
||||||
const existingRaw = readRawConfigFile(filePath).raw;
|
const existingRaw = readRawConfigFile(filePath).raw;
|
||||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||||
const existingHook = stripDetectorKeys(hookSection(existing));
|
const existingHookSection = hookSection(existing);
|
||||||
|
const existingHook = stripDetectorKeys(existingHookSection);
|
||||||
|
const legacyDetector = pickDetectorKeys(existingHookSection);
|
||||||
// Merge over the existing hook object so fields the merge helpers don't manage
|
// Merge over the existing hook object so fields the merge helpers don't manage
|
||||||
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
|
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
|
||||||
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
||||||
|
if (Object.keys(legacyDetector).length > 0) {
|
||||||
|
const existingDetector = detectorSection(existing) || {};
|
||||||
|
next.detector = {
|
||||||
|
...existingDetector,
|
||||||
|
...mergeDetectorConfig(existingDetector, mergeDetectorConfig(legacyDetector)),
|
||||||
|
};
|
||||||
|
}
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
||||||
return filePath;
|
return filePath;
|
||||||
@@ -222,10 +240,14 @@ function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
|
|||||||
const existingRaw = readRawConfigFile(filePath).raw;
|
const existingRaw = readRawConfigFile(filePath).raw;
|
||||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||||
const nextHook = stripDetectorKeys(hookSection(existing));
|
const nextHook = stripDetectorKeys(hookSection(existing));
|
||||||
const existingDetector = mergeDetectorConfig(detectorSection(existing));
|
const existingDetectorSection = detectorSection(existing) || {};
|
||||||
|
const existingDetector = mergeDetectorConfig(existingDetectorSection);
|
||||||
const next = {
|
const next = {
|
||||||
...existing,
|
...existing,
|
||||||
detector: mergeDetectorConfig(detectorConfig, existingDetector),
|
detector: {
|
||||||
|
...existingDetectorSection,
|
||||||
|
...mergeDetectorConfig(detectorConfig, existingDetector),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
|
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
|
||||||
else delete next.hook;
|
else delete next.hook;
|
||||||
@@ -259,12 +281,18 @@ function mergeDetectorConfig(existing, seed = null) {
|
|||||||
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
|
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
|
||||||
out.designSystem = { ...seed.designSystem };
|
out.designSystem = { ...seed.designSystem };
|
||||||
}
|
}
|
||||||
|
if (seed?.advisoryRules === 'include' || seed?.advisoryRules === 'exclude') {
|
||||||
|
out.advisoryRules = seed.advisoryRules;
|
||||||
|
}
|
||||||
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
|
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
|
||||||
out.designSystem = {
|
out.designSystem = {
|
||||||
...(out.designSystem || {}),
|
...(out.designSystem || {}),
|
||||||
enabled: base.designSystem.enabled === false ? false : true,
|
enabled: base.designSystem.enabled === false ? false : true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (base.advisoryRules === 'include' || base.advisoryRules === 'exclude') {
|
||||||
|
out.advisoryRules = base.advisoryRules;
|
||||||
|
}
|
||||||
if (Array.isArray(base.ignoreRules)) {
|
if (Array.isArray(base.ignoreRules)) {
|
||||||
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
|
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
|
||||||
}
|
}
|
||||||
@@ -558,12 +586,44 @@ function addIgnoreRule(cwd, args) {
|
|||||||
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
|
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addIgnoreFile(cwd, glob) {
|
function parseIgnoreFileArgs(args) {
|
||||||
|
const positionals = [];
|
||||||
|
let shared = false;
|
||||||
|
let local = false;
|
||||||
|
|
||||||
|
for (const raw of args) {
|
||||||
|
const arg = String(raw || '');
|
||||||
|
if (arg === '--shared') {
|
||||||
|
shared = true;
|
||||||
|
} else if (arg === '--local') {
|
||||||
|
local = true;
|
||||||
|
} else if (arg === '--reason' || arg.startsWith('--reason=')) {
|
||||||
|
throw new Error('--reason is not supported for ignore-file because detector.ignoreFiles stores globs only; use ignore-value when a documented rule-specific exception fits');
|
||||||
|
} else if (arg.startsWith('--')) {
|
||||||
|
throw new Error(`Unknown ignore-file flag: ${arg}`);
|
||||||
|
} else {
|
||||||
|
positionals.push(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shared && local) throw new Error('Pass only one scope flag: --shared or --local');
|
||||||
|
if (positionals.length > 1) throw new Error('Pass exactly one glob to ignore-file');
|
||||||
|
|
||||||
|
return {
|
||||||
|
glob: positionals[0],
|
||||||
|
local,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIgnoreFile(cwd, args) {
|
||||||
|
const parsed = parseIgnoreFileArgs(args);
|
||||||
|
const glob = parsed.glob;
|
||||||
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
|
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
|
||||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
|
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local: parsed.local }));
|
||||||
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
||||||
writeDetectorConfig(cwd, config);
|
const target = writeDetectorConfig(cwd, config, { local: parsed.local });
|
||||||
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
|
const scope = parsed.local ? 'local detector.ignoreFiles' : 'shared detector.ignoreFiles';
|
||||||
|
return `Added "${glob}" to ${scope} (${path.relative(cwd, target) || target}). Current: ${config.ignoreFiles.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
|
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
|
||||||
@@ -727,7 +787,7 @@ function main() {
|
|||||||
case 'on': out = setEnabled(cwd, true); break;
|
case 'on': out = setEnabled(cwd, true); break;
|
||||||
case 'off': out = setEnabled(cwd, false); break;
|
case 'off': out = setEnabled(cwd, false); break;
|
||||||
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
|
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
|
||||||
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
|
case 'ignore-file': out = addIgnoreFile(cwd, rest); break;
|
||||||
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
|
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
|
||||||
case 'reset': out = reset(cwd); break;
|
case 'reset': out = reset(cwd); break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
designSystemOptions,
|
designSystemOptions,
|
||||||
filterFindings,
|
filterFindings,
|
||||||
isNativePlatform,
|
isNativePlatform,
|
||||||
|
isScanTargetInsideProject,
|
||||||
loadDetector,
|
loadDetector,
|
||||||
matchConfiguredExtension,
|
matchConfiguredExtension,
|
||||||
matchesAnyGlob,
|
matchesAnyGlob,
|
||||||
@@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readExistingProjectFile(filePath, cwd) {
|
function readExistingProjectFile(filePath, cwd) {
|
||||||
if (!isInsideProject(filePath, cwd)) return null;
|
if (!isScanTargetInsideProject(filePath, cwd)) return null;
|
||||||
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
|
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
@@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) {
|
|||||||
const source = shellCopyPaths(command)?.source;
|
const source = shellCopyPaths(command)?.source;
|
||||||
if (!source) return '';
|
if (!source) return '';
|
||||||
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
|
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
|
||||||
if (!isInsideProject(sourcePath, cwd)) return '';
|
if (!isScanTargetInsideProject(sourcePath, cwd)) return '';
|
||||||
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
|
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(sourcePath);
|
const stat = fs.statSync(sourcePath);
|
||||||
@@ -328,15 +329,6 @@ function relativePath(filePath, cwd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInsideProject(filePath, cwd) {
|
|
||||||
try {
|
|
||||||
const rel = path.relative(cwd, filePath);
|
|
||||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||||
@@ -414,7 +406,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
|
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
|
||||||
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
||||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||||
|
|
||||||
|
|||||||
@@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve a path to its canonical (symlink-free) form. When the path does
|
||||||
|
// not exist yet — the before-edit hook gates proposed Writes — canonicalize
|
||||||
|
// the nearest existing ancestor and re-append the remainder, so a new file
|
||||||
|
// under a symlinked root still compares equal to its canonical project.
|
||||||
|
// Memoized: the hook runs as a fresh process per tool event, so the cache
|
||||||
|
// amounts to once-per-event work — the scan loops re-check the same project
|
||||||
|
// root for every target file. The cap only matters to long-lived importers
|
||||||
|
// like the test runner.
|
||||||
|
const canonicalPathCache = new Map();
|
||||||
|
const CANONICAL_PATH_CACHE_MAX = 1024;
|
||||||
|
|
||||||
|
function canonicalPath(p) {
|
||||||
|
const resolved = path.resolve(p);
|
||||||
|
if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved);
|
||||||
|
let canonical = resolved;
|
||||||
|
let dir = resolved;
|
||||||
|
const tail = [];
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir);
|
||||||
|
break;
|
||||||
|
} catch { /* keep climbing */ }
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) break;
|
||||||
|
tail.unshift(path.basename(dir));
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear();
|
||||||
|
canonicalPathCache.set(resolved, canonical);
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Containment gate shared by the before-edit hook and both scan passes. A
|
||||||
|
// session routinely touches files that belong to no project or to a
|
||||||
|
// different one — harness scratchpad dirs under the system temp root,
|
||||||
|
// sibling checkouts, one-off throwaway HTML — and findings against those are
|
||||||
|
// judged with THIS project's config and DESIGN.md palette, which is never
|
||||||
|
// right. Skip them (audit reason: outside-project). Paths are canonicalized
|
||||||
|
// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the
|
||||||
|
// comparison.
|
||||||
|
export function isScanTargetInsideProject(filePath, projectCwd) {
|
||||||
|
if (!filePath || !projectCwd) return false;
|
||||||
|
return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd));
|
||||||
|
}
|
||||||
|
|
||||||
export function parseStaticStyleImports(content, fromFile, projectCwd) {
|
export function parseStaticStyleImports(content, fromFile, projectCwd) {
|
||||||
if (!content || typeof content !== 'string') return [];
|
if (!content || typeof content !== 'string') return [];
|
||||||
const dir = path.dirname(fromFile);
|
const dir = path.dirname(fromFile);
|
||||||
@@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|||||||
lastSkip = 'file-missing';
|
lastSkip = 'file-missing';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!isScanTargetInsideProject(filePath, projectCwd)) {
|
||||||
|
lastSkip = 'outside-project';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
|
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
|
||||||
if (maxFileBytes > 0) {
|
if (maxFileBytes > 0) {
|
||||||
@@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
|||||||
const relForMatch = relativize(filePath, projectCwd);
|
const relForMatch = relativize(filePath, projectCwd);
|
||||||
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
|
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
|
||||||
if (!fs.existsSync(filePath)) continue;
|
if (!fs.existsSync(filePath)) continue;
|
||||||
|
// Caches written before this gate existed can still hold out-of-project
|
||||||
|
// paths, so the Stop pass re-checks containment rather than trusting
|
||||||
|
// the per-edit pass to have filtered them.
|
||||||
|
if (!isScanTargetInsideProject(filePath, projectCwd)) continue;
|
||||||
|
|
||||||
scanned += 1;
|
scanned += 1;
|
||||||
let content = '';
|
let content = '';
|
||||||
|
|||||||
@@ -50,9 +50,36 @@ export function normalizeConceptForm(value) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateConceptEntry(concept, { existingForms = new Map() } = {}) {
|
export function validateConceptEntry(concept, { existingForms = new Map(), axes = null } = {}) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const id = concept?.id || '(unknown)';
|
const id = concept?.id || '(unknown)';
|
||||||
|
|
||||||
|
// Recorded aesthetic axis values. Optional, and absent means the value is
|
||||||
|
// inferred from the system rules instead. Some axes cannot be inferred at all:
|
||||||
|
// depth's keyword probe matched worlds that said "no cast shadow anywhere",
|
||||||
|
// and motion and colour strategy describe properties the rules never state, so
|
||||||
|
// a wave that assigns those has to record them or the assignment is lost.
|
||||||
|
// Validated against the axes definition when the caller supplies it, because a
|
||||||
|
// typo would read as "unrecorded" and silently fall back to a probe that is
|
||||||
|
// known not to work.
|
||||||
|
if (concept?.axes !== undefined && concept.axes !== null) {
|
||||||
|
if (typeof concept.axes !== 'object' || Array.isArray(concept.axes)) {
|
||||||
|
errors.push(`concept ${id} axes must be an object of axis id to value id`);
|
||||||
|
} else if (axes) {
|
||||||
|
const byId = new Map((axes.axes || []).map(axis => [axis.id, axis]));
|
||||||
|
for (const [axisId, valueId] of Object.entries(concept.axes)) {
|
||||||
|
const axis = byId.get(axisId);
|
||||||
|
if (!axis) {
|
||||||
|
errors.push(`concept ${id} names unknown axis "${axisId}"`);
|
||||||
|
} else if (!(axis.values || []).some(value => value.id === valueId)) {
|
||||||
|
errors.push(
|
||||||
|
`concept ${id} axis "${axisId}" has unknown value "${valueId}" `
|
||||||
|
+ `(expected one of ${(axis.values || []).map(v => v.id).join(', ')})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
|
||||||
errors.push(`invalid concept id: ${String(concept?.id)}`);
|
errors.push(`invalid concept id: ${String(concept?.id)}`);
|
||||||
}
|
}
|
||||||
@@ -82,6 +109,18 @@ export function validateConceptEntry(concept, { existingForms = new Map() } = {}
|
|||||||
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||||
errors.push(`concept ${id} must have exactly three structural tags`);
|
errors.push(`concept ${id} must have exactly three structural tags`);
|
||||||
}
|
}
|
||||||
|
// The slop this world in particular is at risk of. Optional, because 541
|
||||||
|
// entries predate it and none of them are wrong for lacking it. A world built
|
||||||
|
// from posters is at risk of shouting and one built from instruments is at
|
||||||
|
// risk of dead greys; a global detector cannot know which, and the author can.
|
||||||
|
if (concept?.avoid !== undefined) {
|
||||||
|
if (!Array.isArray(concept.avoid)
|
||||||
|
|| concept.avoid.length < 2
|
||||||
|
|| concept.avoid.length > 3
|
||||||
|
|| concept.avoid.some(item => typeof item !== 'string' || item.trim().length < 12 || item.trim().length > 160)) {
|
||||||
|
errors.push(`concept ${id} avoid must be two or three negations of 12–160 characters`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!Array.isArray(concept?.system)
|
if (!Array.isArray(concept?.system)
|
||||||
|| concept.system.length !== SYSTEM_PREFIXES.length
|
|| concept.system.length !== SYSTEM_PREFIXES.length
|
||||||
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
||||||
|
|||||||
@@ -2,15 +2,20 @@
|
|||||||
// the live-mode design-system panel can render. Deterministic, dependency-free.
|
// the live-mode design-system panel can render. Deterministic, dependency-free.
|
||||||
//
|
//
|
||||||
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
|
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
|
||||||
// (prose with six canonical H2 sections). When frontmatter is present, it's
|
// (prose with eight canonical H2 sections). When frontmatter is present, it's
|
||||||
// exposed on `model.frontmatter` alongside the prose-scraped sections;
|
// exposed on `model.frontmatter` alongside the prose-scraped sections;
|
||||||
// consumers can prefer frontmatter values and fall back to prose.
|
// consumers can prefer frontmatter values and fall back to prose.
|
||||||
|
|
||||||
|
// Array order is also match precedence: matchCanonicalSection's keyword-contained
|
||||||
|
// pass returns the first entry a heading contains, so reordering this changes
|
||||||
|
// which section an ambiguous heading resolves to.
|
||||||
const CANONICAL_SECTIONS = [
|
const CANONICAL_SECTIONS = [
|
||||||
'Overview',
|
'Overview',
|
||||||
'Colors',
|
'Colors',
|
||||||
'Typography',
|
'Typography',
|
||||||
|
'Layout',
|
||||||
'Elevation',
|
'Elevation',
|
||||||
|
'Shapes',
|
||||||
'Components',
|
'Components',
|
||||||
"Do's and Don'ts",
|
"Do's and Don'ts",
|
||||||
];
|
];
|
||||||
@@ -115,10 +120,71 @@ function stripInlineYamlComment(s) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YAML double-quoted scalars process backslash escapes. Stripping the outer
|
||||||
|
// quotes without unescaping leaves them in place, so a nested font family like
|
||||||
|
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
|
||||||
|
// keeps its literal backslashes and never matches the same family in CSS.
|
||||||
|
// The full YAML 1.2 double-quote escape set (spec section 5.7).
|
||||||
|
const YAML_SIMPLE_ESCAPES = {
|
||||||
|
'0': '\0',
|
||||||
|
a: '\x07',
|
||||||
|
b: '\b',
|
||||||
|
t: '\t',
|
||||||
|
n: '\n',
|
||||||
|
v: '\v',
|
||||||
|
f: '\f',
|
||||||
|
r: '\r',
|
||||||
|
e: '\x1b',
|
||||||
|
' ': ' ',
|
||||||
|
'"': '"',
|
||||||
|
'/': '/',
|
||||||
|
'\\': '\\',
|
||||||
|
N: '\u0085',
|
||||||
|
_: '\u00a0',
|
||||||
|
L: '\u2028',
|
||||||
|
P: '\u2029',
|
||||||
|
};
|
||||||
|
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
|
||||||
|
|
||||||
|
function unescapeYamlDoubleQuoted(body) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
const ch = body[i];
|
||||||
|
if (ch !== '\\' || i === body.length - 1) {
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = body[i + 1];
|
||||||
|
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
|
||||||
|
out += YAML_SIMPLE_ESCAPES[next];
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
|
||||||
|
// literal rather than corrupting the rest of the scalar.
|
||||||
|
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
|
||||||
|
if (hexLen) {
|
||||||
|
const hex = body.slice(i + 2, i + 2 + hexLen);
|
||||||
|
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
|
||||||
|
if (codePoint >= 0 && codePoint <= 0x10ffff) {
|
||||||
|
out += String.fromCodePoint(codePoint);
|
||||||
|
i += 1 + hexLen;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function parseScalar(raw) {
|
function parseScalar(raw) {
|
||||||
const s = raw.trim();
|
const s = raw.trim();
|
||||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||||
return s.slice(1, -1);
|
return unescapeYamlDoubleQuoted(s.slice(1, -1));
|
||||||
|
}
|
||||||
|
// Single-quoted YAML escapes only the quote itself, by doubling it.
|
||||||
|
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
|
||||||
|
return s.slice(1, -1).split("''").join("'");
|
||||||
}
|
}
|
||||||
if (s === 'true') return true;
|
if (s === 'true') return true;
|
||||||
if (s === 'false') return false;
|
if (s === 'false') return false;
|
||||||
@@ -330,17 +396,16 @@ function extractOverview(section) {
|
|||||||
if (!section) return null;
|
if (!section) return null;
|
||||||
const text = section.lines.join('\n');
|
const text = section.lines.join('\n');
|
||||||
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
|
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
|
||||||
const keyChars = [];
|
|
||||||
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
|
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
|
||||||
if (keyCharMatch) {
|
const keyChars = keyCharMatch
|
||||||
for (const line of keyCharMatch[1].split('\n')) {
|
? collectBullets(keyCharMatch[1].split('\n')).map((bullet) => stripBold(bullet.trim()))
|
||||||
const m = line.match(/^\s*[-*]\s+(.+)$/);
|
: [];
|
||||||
if (m) keyChars.push(stripBold(m[1].trim()));
|
const prose = keyCharMatch
|
||||||
}
|
? text.slice(0, keyCharMatch.index) + text.slice(keyCharMatch.index + keyCharMatch[0].length)
|
||||||
}
|
: text;
|
||||||
|
|
||||||
// Philosophy paragraphs: everything that isn't a rule header or key-char block
|
// Philosophy paragraphs: everything that isn't a rule header or key-char block
|
||||||
const paragraphs = collectParagraphs(section.lines).filter(
|
const paragraphs = collectParagraphs(prose.split('\n')).filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
!p.startsWith('**Creative North Star') &&
|
!p.startsWith('**Creative North Star') &&
|
||||||
!p.startsWith('**Key Characteristics')
|
!p.startsWith('**Key Characteristics')
|
||||||
@@ -602,11 +667,19 @@ function parseTypeBullet(bullet) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractElevation(section) {
|
function extractGuidance(section) {
|
||||||
if (!section) return null;
|
if (!section) return null;
|
||||||
const subs = splitSubsections(section.lines);
|
const subs = splitSubsections(section.lines);
|
||||||
|
return {
|
||||||
|
subtitle: section.subtitle,
|
||||||
|
description: collectParagraphs(subs[0].lines).join(' ') || null,
|
||||||
|
rules: extractNamedRules(section.lines),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const description = collectParagraphs(subs[0].lines).join(' ') || null;
|
function extractElevation(section) {
|
||||||
|
const guidance = extractGuidance(section);
|
||||||
|
if (!guidance) return null;
|
||||||
|
|
||||||
const shadows = [];
|
const shadows = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -631,12 +704,7 @@ function extractElevation(section) {
|
|||||||
for (const inline of extractInlineShadows(b)) dedupe(inline);
|
for (const inline of extractInlineShadows(b)) dedupe(inline);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { ...guidance, shadows };
|
||||||
subtitle: section.subtitle,
|
|
||||||
description,
|
|
||||||
shadows,
|
|
||||||
rules: extractNamedRules(section.lines),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractInlineShadows(text) {
|
function extractInlineShadows(text) {
|
||||||
@@ -768,6 +836,15 @@ function extractDosDonts(section) {
|
|||||||
|
|
||||||
// ---------- Coverage assessment ----------
|
// ---------- Coverage assessment ----------
|
||||||
|
|
||||||
|
// Sections whose model is description-plus-rules only (see extractGuidance).
|
||||||
|
const guidanceCoverage = (guidance) =>
|
||||||
|
guidance
|
||||||
|
? {
|
||||||
|
description: Boolean(guidance.description),
|
||||||
|
rules: guidance.rules.length,
|
||||||
|
}
|
||||||
|
: 'missing';
|
||||||
|
|
||||||
function assessCoverage(model) {
|
function assessCoverage(model) {
|
||||||
const report = {};
|
const report = {};
|
||||||
|
|
||||||
@@ -796,6 +873,8 @@ function assessCoverage(model) {
|
|||||||
}
|
}
|
||||||
: 'missing';
|
: 'missing';
|
||||||
|
|
||||||
|
report.layout = guidanceCoverage(model.layout);
|
||||||
|
|
||||||
report.elevation = model.elevation
|
report.elevation = model.elevation
|
||||||
? {
|
? {
|
||||||
shadows: model.elevation.shadows.length,
|
shadows: model.elevation.shadows.length,
|
||||||
@@ -804,6 +883,8 @@ function assessCoverage(model) {
|
|||||||
}
|
}
|
||||||
: 'missing';
|
: 'missing';
|
||||||
|
|
||||||
|
report.shapes = guidanceCoverage(model.shapes);
|
||||||
|
|
||||||
report.components = model.components
|
report.components = model.components
|
||||||
? {
|
? {
|
||||||
count: model.components.components.length,
|
count: model.components.components.length,
|
||||||
@@ -833,7 +914,9 @@ export function parseDesignMd(md) {
|
|||||||
overview: extractOverview(sections['Overview']),
|
overview: extractOverview(sections['Overview']),
|
||||||
colors: extractColors(sections['Colors']),
|
colors: extractColors(sections['Colors']),
|
||||||
typography: extractTypography(sections['Typography']),
|
typography: extractTypography(sections['Typography']),
|
||||||
|
layout: extractGuidance(sections['Layout']),
|
||||||
elevation: extractElevation(sections['Elevation']),
|
elevation: extractElevation(sections['Elevation']),
|
||||||
|
shapes: extractGuidance(sections['Shapes']),
|
||||||
components: extractComponents(sections['Components']),
|
components: extractComponents(sections['Components']),
|
||||||
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
|
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
export function browserOpenCommand(url, {
|
||||||
|
platform = process.platform,
|
||||||
|
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
|
||||||
|
} = {}) {
|
||||||
|
if (platform === 'darwin') return { command: 'open', args: [url] };
|
||||||
|
if (platform === 'win32') return { command: comspec, args: ['/c', 'start', '', url] };
|
||||||
|
return { command: 'xdg-open', args: [url] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSystemBrowser(url, {
|
||||||
|
platform = process.platform,
|
||||||
|
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
|
||||||
|
spawnImpl = spawn,
|
||||||
|
} = {}) {
|
||||||
|
const { command, args } = browserOpenCommand(url, { platform, comspec });
|
||||||
|
try {
|
||||||
|
const child = spawnImpl(command, args, { stdio: 'ignore', detached: true });
|
||||||
|
child.on('error', () => {});
|
||||||
|
child.unref();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,31 +96,38 @@ function* rank(items, input, idFor = item => item.id) {
|
|||||||
.map(entry => entry.item);
|
.map(entry => entry.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two independent exclusions, and either one is enough to hold a world back.
|
// Rating sets how many tickets a world holds; breadth decides whether it draws
|
||||||
// Rating grades quality: a 3-star earns a second ticket, a 1-star marginal keep
|
// at all. A niche world leaves the pool however good it is, keeping its approval
|
||||||
// leaves the pool. Breadth says whether a world can serve an arbitrary build at
|
// for direct briefs. Breadth was split out of rating because the only way to
|
||||||
// all, so a niche world leaves however good it is, keeping its approval for
|
// hold a narrow world back used to be calling it marginal, which made "excellent
|
||||||
// direct briefs. Breadth was split out of rating because the only way to hold a
|
// but narrow" unrecordable and corrupted ratings as a calibration signal.
|
||||||
// narrow world back used to be calling it marginal, which made "excellent but
|
//
|
||||||
// narrow" unrecordable and corrupted ratings as a calibration signal.
|
// Two tickets for a 3-star, one for everything else, was too sharp. Measured
|
||||||
|
// against the catalog as it stood: 3-star worlds absorbed 57% of the graphic
|
||||||
|
// draw from 65 of 163 eligible worlds, 46% of atmosphere from 13 of 43, and
|
||||||
|
// 75% of interaction from 15 of 25. The reviewer's complaint, that the same
|
||||||
|
// worlds keep coming back, is what a rating multiplier does to a pool whose
|
||||||
|
// thinnest tier holds 25 worlds.
|
||||||
|
//
|
||||||
|
// So a 3-star no longer outdraws a 2-star, and a 1-star draws at half rather
|
||||||
|
// than not at all. A marginal keep is still worth showing sometimes: the
|
||||||
|
// judgement it records is "narrow or unexceptional", not "wrong", and excluding
|
||||||
|
// it entirely made a rating do a job breadth already does properly.
|
||||||
|
const RATING_TICKETS = { 1: 1, 2: 2, 3: 2 };
|
||||||
|
const ticketsForRating = rating => RATING_TICKETS[rating] ?? 2;
|
||||||
|
|
||||||
function challengerTickets(pool) {
|
function challengerTickets(pool) {
|
||||||
return pool.flatMap(concept => {
|
return pool.flatMap(concept => {
|
||||||
const rating = concept.review?.rating;
|
if (concept.review?.breadth === 'niche') return [];
|
||||||
if (rating === 1 || concept.review?.breadth === 'niche') return [];
|
return Array.from({ length: ticketsForRating(concept.review?.rating) },
|
||||||
return rating === 3
|
(_, ticket) => ({ concept, ticket }));
|
||||||
? [{ concept, ticket: 0 }, { concept, ticket: 1 }]
|
|
||||||
: [{ concept, ticket: 0 }];
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function compositionTickets(pool) {
|
function compositionTickets(pool) {
|
||||||
return pool.flatMap(composition => {
|
return pool.flatMap(composition => Array.from(
|
||||||
const rating = composition.review?.rating;
|
{ length: ticketsForRating(composition.review?.rating) },
|
||||||
if (rating === 1) return [];
|
(_, ticket) => ({ composition, ticket })));
|
||||||
return rating === 3
|
|
||||||
? [{ composition, ticket: 0 }, { composition, ticket: 1 }]
|
|
||||||
: [{ composition, ticket: 0 }];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -117,6 +117,23 @@ export function checkDesignDrift({ designPath, projectRoot, threshold = 25 }) {
|
|||||||
* a section can be absent because it never applied, so this is reported as a
|
* a section can be absent because it never applied, so this is reported as a
|
||||||
* documentation gap for a human to judge, never as an error.
|
* documentation gap for a human to judge, never as an error.
|
||||||
*/
|
*/
|
||||||
|
function hasCoverageValue(value) {
|
||||||
|
if (Array.isArray(value)) return value.some(hasCoverageValue);
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.values(value).some(hasCoverageValue);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 && !/^(?:\[\s*\]|\{\s*\})$/.test(trimmed);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEED_DESIGN_MARKERS = ['/', '$'].map((prefix) =>
|
||||||
|
'<!-- SEED: established with the user before implementation; '
|
||||||
|
+ `re-run ${prefix}impeccable document once there's code to capture the actual tokens and components. -->`
|
||||||
|
);
|
||||||
|
|
||||||
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
||||||
if (!design || typeof parseDesignMd !== 'function') return [];
|
if (!design || typeof parseDesignMd !== 'function') return [];
|
||||||
let model;
|
let model;
|
||||||
@@ -125,8 +142,12 @@ export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
|||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const missing = ['colors', 'typography', 'components']
|
const isSeed = SEED_DESIGN_MARKERS.some((marker) => design.includes(marker));
|
||||||
.filter((section) => !model[section]);
|
const requiredSections = isSeed
|
||||||
|
? ['colors', 'typography']
|
||||||
|
: ['colors', 'typography', 'components'];
|
||||||
|
const missing = requiredSections
|
||||||
|
.filter((section) => !model[section] && !hasCoverageValue(model.frontmatter?.[section]));
|
||||||
if (!missing.length) return [];
|
if (!missing.length) return [];
|
||||||
return [finding({
|
return [finding({
|
||||||
id: 'design-md-coverage',
|
id: 'design-md-coverage',
|
||||||
|
|||||||
@@ -3766,7 +3766,10 @@
|
|||||||
const container = copyEditContainerContext(contextElement);
|
const container = copyEditContainerContext(contextElement);
|
||||||
if (container) for (const op of ops) op.container = container;
|
if (container) for (const op of ops) op.container = container;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', {
|
// Token in the query string as well as the body: the URL token is what
|
||||||
|
// authorizes the CORS preflight when the page runs on a non-loopback
|
||||||
|
// dev host (ddev, Valet), since the preflight carries no request body.
|
||||||
|
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -7150,7 +7153,10 @@
|
|||||||
console.debug('[impeccable] Dropped optional live event:', err);
|
console.debug('[impeccable] Dropped optional live event:', err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const doSend = () => fetch('http://localhost:' + PORT + '/events', {
|
// Token in the query string as well as the body: the URL token is what
|
||||||
|
// authorizes the CORS preflight when the page runs on a non-loopback
|
||||||
|
// dev host (ddev, Valet), since the preflight carries no request body.
|
||||||
|
const doSend = () => fetch('http://localhost:' + PORT + '/events?token=' + encodeURIComponent(TOKEN), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(msg),
|
body: JSON.stringify(msg),
|
||||||
@@ -11969,7 +11975,9 @@ void main() {
|
|||||||
rules: [
|
rules: [
|
||||||
...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
|
...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
|
||||||
...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
|
...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
|
||||||
|
...(md.layout?.rules || []).map((r) => ({ ...r, section: 'layout' })),
|
||||||
...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
|
...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
|
||||||
|
...(md.shapes?.rules || []).map((r) => ({ ...r, section: 'shapes' })),
|
||||||
],
|
],
|
||||||
dos: md.dosDonts?.dos || [],
|
dos: md.dosDonts?.dos || [],
|
||||||
donts: md.dosDonts?.donts || [],
|
donts: md.dosDonts?.donts || [],
|
||||||
|
|||||||
@@ -689,16 +689,24 @@ function isLoopbackOrigin(origin) {
|
|||||||
function createRequestHandler({ detectScript, liveScriptParts }) {
|
function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||||
return (req, res) => {
|
return (req, res) => {
|
||||||
const url = new URL(req.url, `http://localhost:${state.port}`);
|
const url = new URL(req.url, `http://localhost:${state.port}`);
|
||||||
// Loopback-restricted CORS. Reflect the caller's Origin only when it is a
|
// Token-or-loopback CORS. Reflect the caller's Origin when it is a
|
||||||
// loopback origin, always paired with `Vary: Origin` so an intermediary
|
// loopback origin OR the request carries the valid session token, always
|
||||||
// cache never serves a response authorized for one origin to another. A
|
// paired with `Vary: Origin` so an intermediary cache never serves a
|
||||||
// remote page (e.g. https://evil.example probing the port from a tab open
|
// response authorized for one origin to another. A remote page (e.g.
|
||||||
// on the same machine) gets no Access-Control-Allow-Origin, so its
|
// https://evil.example probing the port from a tab open on the same
|
||||||
// JS-initiated fetch cannot read any response. Requests with no Origin
|
// machine) has no token and gets no Access-Control-Allow-Origin, so its
|
||||||
// header (script tags, curl, the agent's own fetches) are not subject to
|
// JS-initiated fetch cannot read any response. The token branch exists for
|
||||||
// CORS and keep working; no ACAO header is needed for them.
|
// dev servers on non-localhost loopback aliases (ddev's *.ddev.site,
|
||||||
|
// Valet's *.test, hosts-file entries): the injected classic <script src>
|
||||||
|
// delivers the token to the page regardless of origin, every overlay
|
||||||
|
// request carries it in the query string (preflights included, since
|
||||||
|
// OPTIONS hits the same URL), and a token bearer is already fully
|
||||||
|
// authorized on every route — the token is the security boundary, not the
|
||||||
|
// origin. Requests with no Origin header (script tags, curl, the agent's
|
||||||
|
// own fetches) are not subject to CORS and keep working; no ACAO header
|
||||||
|
// is needed for them.
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (origin && isLoopbackOrigin(origin)) {
|
if (origin && (isLoopbackOrigin(origin) || url.searchParams.get('token') === state.token)) {
|
||||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
res.setHeader('Vary', 'Origin');
|
res.setHeader('Vary', 'Origin');
|
||||||
}
|
}
|
||||||
@@ -865,7 +873,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
|||||||
// { present, parsed, sidecar, hasMd, hasSidecar,
|
// { present, parsed, sidecar, hasMd, hasSidecar,
|
||||||
// mdNewerThanJson, parseError?, sidecarError? }
|
// mdNewerThanJson, parseError?, sidecarError? }
|
||||||
// - parsed: output of parseDesignMd (frontmatter
|
// - parsed: output of parseDesignMd (frontmatter
|
||||||
// + six canonical sections) when DESIGN.md exists.
|
// + the canonical sections) when DESIGN.md exists.
|
||||||
// - sidecar: .impeccable/design.json contents when present.
|
// - sidecar: .impeccable/design.json contents when present.
|
||||||
// Expected shape: schemaVersion 2, carrying
|
// Expected shape: schemaVersion 2, carrying
|
||||||
// extensions + components + narrative.
|
// extensions + components + narrative.
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* Framework-neutral Impeccable live chrome contract.
|
|
||||||
*
|
|
||||||
* The production browser bundle is intentionally plain DOM so Svelte, React,
|
|
||||||
* Vue, and static adapters can all mount the same chrome. This module is the
|
|
||||||
* testable contract/inventory for that bundle; live-browser.js mirrors these
|
|
||||||
* values at runtime because it is served as a standalone script.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
|
|
||||||
'root',
|
|
||||||
'transport',
|
|
||||||
'state',
|
|
||||||
'actions',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const LIVE_UI_SURFACES = Object.freeze([
|
|
||||||
{
|
|
||||||
key: 'global-bottom-bar',
|
|
||||||
ids: [
|
|
||||||
'impeccable-live-global-bar',
|
|
||||||
'impeccable-live-global-bar-brand',
|
|
||||||
'impeccable-live-pick-toggle',
|
|
||||||
'impeccable-live-insert-toggle',
|
|
||||||
'impeccable-live-detect-toggle',
|
|
||||||
'impeccable-live-detect-badge',
|
|
||||||
'impeccable-live-design-toggle',
|
|
||||||
'impeccable-live-page-chat',
|
|
||||||
'impeccable-live-page-chat-input',
|
|
||||||
'impeccable-live-page-chat-voice',
|
|
||||||
],
|
|
||||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'pending-copy-edit-dock',
|
|
||||||
ids: ['impeccable-live-pending-dock'],
|
|
||||||
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'element-selection-chrome',
|
|
||||||
ids: [
|
|
||||||
'impeccable-live-highlight',
|
|
||||||
'impeccable-live-tooltip',
|
|
||||||
'impeccable-live-bar',
|
|
||||||
'impeccable-live-selection-pill',
|
|
||||||
'impeccable-live-input',
|
|
||||||
'impeccable-live-configure-voice',
|
|
||||||
'impeccable-live-configure-bar-tooltip',
|
|
||||||
],
|
|
||||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'action-picker',
|
|
||||||
ids: ['impeccable-live-picker'],
|
|
||||||
states: ['closed', 'open', 'option-hover', 'option-focus'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'edit-chrome',
|
|
||||||
ids: ['impeccable-live-edit-badge'],
|
|
||||||
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'generating-row',
|
|
||||||
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
|
|
||||||
states: ['action-label', 'animated-dots', 'generating', 'done'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'variant-cycling-row',
|
|
||||||
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
|
|
||||||
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'variant-params-panel',
|
|
||||||
ids: ['impeccable-live-params-panel'],
|
|
||||||
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'saving-confirmed-rows',
|
|
||||||
ids: ['impeccable-live-bar'],
|
|
||||||
states: ['saving', 'applying-variant', 'confirmed'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'insert-mode-chrome',
|
|
||||||
ids: [
|
|
||||||
'impeccable-live-insert-line',
|
|
||||||
'impeccable-live-insert-placeholder',
|
|
||||||
'impeccable-live-placeholder-resize',
|
|
||||||
'impeccable-live-insert-input',
|
|
||||||
'impeccable-live-insert-voice',
|
|
||||||
'impeccable-live-insert-create',
|
|
||||||
'impeccable-live-insert-create-tooltip',
|
|
||||||
],
|
|
||||||
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'annotation-chrome',
|
|
||||||
ids: [
|
|
||||||
'impeccable-live-annot',
|
|
||||||
'impeccable-live-annot-svg',
|
|
||||||
'impeccable-live-annot-pins',
|
|
||||||
'impeccable-live-annot-clear',
|
|
||||||
],
|
|
||||||
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'design-system-panel',
|
|
||||||
ids: ['impeccable-live-design-host'],
|
|
||||||
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'toasts-and-errors',
|
|
||||||
ids: ['impeccable-live-toast'],
|
|
||||||
states: ['normal', 'error', 'no-variants-mounted'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'css-isolation-boundary',
|
|
||||||
ids: ['impeccable-live-root'],
|
|
||||||
states: ['shadow-root', 'style-tags', 'hostile-css'],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
|
|
||||||
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function resolveLiveUiRoot(env = globalThis) {
|
|
||||||
const doc = env?.document;
|
|
||||||
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|
|
||||||
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
|
|
||||||
if (explicit && typeof explicit.appendChild === 'function') return explicit;
|
|
||||||
return doc?.body || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLiveUiElementById(id, env = globalThis) {
|
|
||||||
const doc = env?.document;
|
|
||||||
const root = resolveLiveUiRoot(env);
|
|
||||||
if (!id) return null;
|
|
||||||
if (root?.getElementById) {
|
|
||||||
const found = root.getElementById(id);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
if (root?.querySelector) {
|
|
||||||
const found = root.querySelector('#' + escapeCssIdent(id));
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return doc?.getElementById?.(id) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendToLiveUiRoot(el, env = globalThis) {
|
|
||||||
const root = resolveLiveUiRoot(env);
|
|
||||||
if (!root) throw new Error('Impeccable live UI root is not available');
|
|
||||||
root.appendChild(el);
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
|
|
||||||
const doc = env?.document;
|
|
||||||
const root = resolveLiveUiRoot(env);
|
|
||||||
if (root && root !== doc?.body) {
|
|
||||||
root.appendChild(styleEl);
|
|
||||||
} else {
|
|
||||||
(doc?.head || doc?.body || root).appendChild(styleEl);
|
|
||||||
}
|
|
||||||
return styleEl;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activeElementDeep(doc = globalThis.document) {
|
|
||||||
let active = doc?.activeElement || null;
|
|
||||||
while (active?.shadowRoot?.activeElement) {
|
|
||||||
active = active.shadowRoot.activeElement;
|
|
||||||
}
|
|
||||||
return active;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeCssIdent(value) {
|
|
||||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
||||||
return CSS.escape(String(value));
|
|
||||||
}
|
|
||||||
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,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', '.github', '.grok',
|
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
|
||||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -93,15 +93,17 @@ function commandPrefixForSkillsDir(skillsDir) {
|
|||||||
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
|
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
function generatePinnedSkill(command, metadata, commandPrefix) {
|
function generatePinnedSkill(command, metadata, commandPrefix, isCodex) {
|
||||||
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
|
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
|
||||||
const hint = metadata[command]?.argumentHint || '[target]';
|
const hint = metadata[command]?.argumentHint || '[target]';
|
||||||
|
const providerFrontmatter = isCodex
|
||||||
|
? `metadata:\n argument-hint: "${hint}"`
|
||||||
|
: `argument-hint: "${hint}"\nuser-invocable: true`;
|
||||||
|
|
||||||
return `---
|
return `---
|
||||||
name: ${command}
|
name: ${command}
|
||||||
description: "${desc}"
|
description: "${desc}"
|
||||||
argument-hint: "${hint}"
|
${providerFrontmatter}
|
||||||
user-invocable: true
|
|
||||||
---
|
---
|
||||||
|
|
||||||
${PIN_MARKER}
|
${PIN_MARKER}
|
||||||
@@ -128,7 +130,7 @@ function pin(command, projectRoot) {
|
|||||||
|
|
||||||
for (const skillsDir of harnessDirs) {
|
for (const skillsDir of harnessDirs) {
|
||||||
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
|
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
|
||||||
const content = generatePinnedSkill(command, metadata, commandPrefix);
|
const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$');
|
||||||
// Check if skill already exists (and isn't a pin)
|
// Check if skill already exists (and isn't a pin)
|
||||||
const skillDir = join(skillsDir, command);
|
const skillDir = join(skillsDir, command);
|
||||||
if (existsSync(skillDir)) {
|
if (existsSync(skillDir)) {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { openSystemBrowser } from './lib/open-system-browser.mjs';
|
||||||
|
|
||||||
function arg(name, fallback = null) {
|
function arg(name, fallback = null) {
|
||||||
const i = process.argv.indexOf(`--${name}`);
|
const i = process.argv.indexOf(`--${name}`);
|
||||||
@@ -143,7 +144,7 @@ if (hasFlag('schema')) {
|
|||||||
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/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/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.', 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: '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: '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' },
|
||||||
],
|
],
|
||||||
reroll: true,
|
reroll: true,
|
||||||
@@ -151,7 +152,7 @@ if (hasFlag('schema')) {
|
|||||||
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.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/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. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
|
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. 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.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +322,11 @@ 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 hasBack = (option) => Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
|
const hasMedia = (option) => Boolean(option.sketchSrc || option.heroSrc || option.boardSrc);
|
||||||
|
// The back exists to keep long facts off a card whose front is an image;
|
||||||
|
// a card with no art has no flip chip to reach it, so it gets no back and
|
||||||
|
// the full read lives on the front instead.
|
||||||
|
const hasBack = (option) => hasMedia(option) && Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
|
||||||
const anatomy = (option) => {
|
const anatomy = (option) => {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
if (option.thesis) rows.push(`<p class="thesis">${esc(option.thesis)}</p>`);
|
if (option.thesis) rows.push(`<p class="thesis">${esc(option.thesis)}</p>`);
|
||||||
@@ -336,7 +341,15 @@ function page() {
|
|||||||
// 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 sketch lands, the first viewport is a picture.
|
||||||
rows.push(fact('Risk', option.risk, 'clamp'));
|
// With no art there is no back, so the full read fills the room the
|
||||||
|
// image would have taken.
|
||||||
|
if (hasMedia(option)) {
|
||||||
|
rows.push(fact('Risk', option.risk, 'clamp'));
|
||||||
|
} else {
|
||||||
|
rows.push(fact('First viewport', option.viewport));
|
||||||
|
rows.push(fact('The case', option.case));
|
||||||
|
rows.push(fact('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>`);
|
||||||
else if (option.body && option.thesis && !hasBack(option)) rows.push(`<p class="detail more">${esc(option.body)}</p>`);
|
else if (option.body && option.thesis && !hasBack(option)) rows.push(`<p class="detail more">${esc(option.body)}</p>`);
|
||||||
return rows.join('\n ');
|
return rows.join('\n ');
|
||||||
@@ -362,8 +375,11 @@ function page() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
if (option.heroSrc || option.boardSrc) {
|
if (option.heroSrc || option.boardSrc) {
|
||||||
return `<div class="media">
|
// Without a sketch the catalog art is the card's face; it stays a
|
||||||
|
// labeled reference so it never reads as the promise of the build.
|
||||||
|
return `<div class="media" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||||
|
<p class="media-label">inspiration</p>
|
||||||
<div class="chips">${expandChip}${details}</div>
|
<div class="chips">${expandChip}${details}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -547,6 +563,17 @@ function page() {
|
|||||||
.pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 3px 0 4px; background: oklch(7% 0.006 95 / 0.72); backdrop-filter: blur(3px); }
|
.pip 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); }
|
.sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||||
|
/* Catalog art standing in for a sketchless card is a reference, and says so
|
||||||
|
on its face; the same pill later carries "artwork unavailable". */
|
||||||
|
.media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); padding: 3px 8px 4px; background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 4px; backdrop-filter: blur(3px); }
|
||||||
|
/* Art that never arrives collapses to the card's own palette (painted
|
||||||
|
inline from its swatches) instead of sitting as a dark void wearing a
|
||||||
|
zoom cursor; the scrim keeps the label legible over saturated fields,
|
||||||
|
passes clicks through, and the flip chips stay above it. A card with no
|
||||||
|
palette falls back to the quiet graphite field. */
|
||||||
|
.media.unavailable { background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
|
||||||
|
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
|
||||||
|
.media.unavailable .chips { z-index: 2; }
|
||||||
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
||||||
the real sketch whenever it lands. */
|
the real sketch whenever it lands. */
|
||||||
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
||||||
@@ -709,6 +736,36 @@ function page() {
|
|||||||
tryLoad();
|
tryLoad();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A declared image that never loads (missing catalog asset, offline shell)
|
||||||
|
// must not sit as a dark void: the slot collapses to the card's own
|
||||||
|
// palette, labeled honestly, and the card competes on its facts. Sketch
|
||||||
|
// slots are excluded; their polling owns the wait.
|
||||||
|
const artFailed = (img) => {
|
||||||
|
const m = img.closest('.media');
|
||||||
|
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
|
||||||
|
m.classList.add('unavailable');
|
||||||
|
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
|
||||||
|
if (colors.length) m.style.background = 'linear-gradient(135deg, ' + colors.map((c, i) => c + ' ' + Math.round(i * 100 / colors.length) + '% ' + Math.round((i + 1) * 100 / colors.length) + '%').join(', ') + ')';
|
||||||
|
m.querySelector('.media-label')?.remove();
|
||||||
|
m.querySelector('.chip.expand')?.remove();
|
||||||
|
m.removeAttribute('title');
|
||||||
|
img.remove();
|
||||||
|
const label = document.createElement('p');
|
||||||
|
label.className = 'media-label';
|
||||||
|
label.textContent = 'artwork unavailable';
|
||||||
|
m.appendChild(label);
|
||||||
|
};
|
||||||
|
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
|
||||||
|
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
||||||
|
else img.addEventListener('error', () => artFailed(img), { once: true });
|
||||||
|
});
|
||||||
|
// A broken inspiration PIP just leaves; nothing depends on it.
|
||||||
|
document.querySelectorAll('.pip img').forEach(img => {
|
||||||
|
const gone = () => img.closest('.pip')?.remove();
|
||||||
|
if (img.complete && img.naturalWidth === 0) gone();
|
||||||
|
else img.addEventListener('error', gone, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Inspiration PIP opens the full catalog card in the lightbox.
|
// Inspiration PIP opens the full catalog card in the lightbox.
|
||||||
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
|
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -920,8 +977,7 @@ server.listen(portArg, '127.0.0.1', () => {
|
|||||||
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
|
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
|
||||||
}
|
}
|
||||||
if (!hasFlag('no-open')) {
|
if (!hasFlag('no-open')) {
|
||||||
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
openSystemBrowser(url);
|
||||||
try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ }
|
|
||||||
}
|
}
|
||||||
if (timeoutSec > 0) {
|
if (timeoutSec > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"",
|
"command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"",
|
||||||
"timeout": 5,
|
"timeout": 5,
|
||||||
"statusMessage": "Checking UI changes"
|
"statusMessage": "Checking UI changes"
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"",
|
"command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"statusMessage": "Design deep pass"
|
"statusMessage": "Design deep pass"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
|
|||||||
## 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
|
||||||
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
|
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.
|
||||||
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.
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"hooks": {
|
"hooks": {
|
||||||
"preToolUse": [
|
"preToolUse": [
|
||||||
{
|
{
|
||||||
"command": "[ ! -f \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\" ] || ! node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || node \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\"",
|
"command": "[ ! -f \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\" ] || ! node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || node \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\"",
|
||||||
"timeout": 5
|
"timeout": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
|
|||||||
- **Type:** body measure 65–75ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
|
- **Type:** body measure 65–75ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
|
||||||
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
|
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
|
||||||
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
|
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
|
||||||
|
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
|
||||||
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
|
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
|
||||||
- **Coverage:** every brief requirement present and findable within seconds.
|
- **Coverage:** every brief requirement present and findable within seconds.
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
|
|||||||
## 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
|
||||||
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
|
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.
|
||||||
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.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ The script assigns which structure gets built; your top-ranked structure is what
|
|||||||
|
|
||||||
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 .cursor/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, 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 .cursor/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||||
|
|
||||||
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, the canon card included. Serve the page first, then produce the sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
|
When image generation exists, every card also declares a `sketch` path under `.impeccable/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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -84,9 +84,12 @@ For `shape`, return the selected direction to [shape.md](shape.md) and stop befo
|
|||||||
|
|
||||||
## 6. Build with full commitment
|
## 6. Build with full commitment
|
||||||
|
|
||||||
|
When an approved comp exists, the comp is king, and the build happens in phases. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses.
|
||||||
|
|
||||||
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
|
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
|
||||||
|
|
||||||
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
|
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
|
||||||
|
- **Prove the hero before building past it.** When an approved comp exists, render the first viewport, capture it, and set it beside the comp's first viewport before any later section: the hero carries the run's ambition, and every following section inherits its shortfall. Judge scale and density as quantities, a field at a tenth of the comp's coverage or type at half its weight is a different design, and a five-minute retry here is what a rebuild verdict at the finish costs when this check is skipped.
|
||||||
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
|
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
|
||||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
|
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
|
||||||
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
|
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
|
||||||
@@ -100,6 +103,6 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
|||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
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 .cursor/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch: put that verdict in front of the user with the named comp regions and let them choose between a re-derivation and shipping as it stands. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
|
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. Where this harness runs no design hook, run `node .cursor/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.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ After approval, record the choice where tools can find it: the approved comp's p
|
|||||||
|
|
||||||
## Inventory implementation fidelity
|
## Inventory implementation fidelity
|
||||||
|
|
||||||
Before building, inventory the approved comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same written inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
|
Before building, read the approved comp as a design system and record it in the brief: component grammar, corner language, line weights, elevation treatment, and the type ramp, because everything the comp does not show gets built from this record, and without it the fallback is the model's stock kit of square boxes, 1px grids, bento cells, and hard shadows. Then inventory the comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same written inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. The primary action gets its own row with its own medium: when the comp dissolves, stamps, erodes, or otherwise physically works the main CTA, that treatment is signature material on the page's most important element, and shrinking it to a border trick or a few decorative pixels is the compliance-token version of commitment. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
|
||||||
|
|
||||||
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack; writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish, is not a medium choice, it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly, diagrams with countable elements, controls, flat shape systems, and it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region instead of producing it is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity, so "no photography on hand" forbids fake proof, not an illustrated hero.
|
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack, and so is any texture by that name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient or layered background is not a texture medium and "layered CSS textures" is not a medium at all. Writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish or a cotton field's weave, is not a medium choice, it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly, diagrams with countable elements, controls, flat shape systems, and it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region instead of producing it is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity, so "no photography on hand" forbids fake proof, not an illustrated hero.
|
||||||
|
|
||||||
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond, and code executed safely and professionally remains first-class there. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
|
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond, and code executed safely and professionally remains first-class there. A field or texture built from many small elements carries a quantity commitment either way: write down its approximate density and coverage ("thousands of glyphs over two-thirds of the fold, dense at the top fading into the path"), because a field rebuilt at a tenth of its density passes every checklist and still is not the design. TYPE rows carry the same discipline: name the face's compression class, and render one headline word against the comp before building on it; a visibly wider or lighter silhouette means the face is wrong, and every section built on it inherits the miss. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
|
||||||
|
|
||||||
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies.
|
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies.
|
||||||
|
|
||||||
@@ -42,6 +42,8 @@ Treat the comp as a north star, not something to trace, and know what that allow
|
|||||||
|
|
||||||
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .cursor/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
|
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .cursor/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
|
||||||
|
|
||||||
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
|
When the harness runs subagents, spawn the shipped asset producer every time, even when the inventory's produce bucket looks empty: its manifest is the independent second opinion on your media, and runs that skipped the spawn are the runs whose cotton became CSS. An honestly empty manifest costs one cheap spawn; a wrongly empty produce bucket costs the build its materials. Use the producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
|
||||||
|
|
||||||
|
Convert images with a converter context.mjs reported at boot (the IMAGE_TOOLS line); probe only when it reported none, at most once per session, never per image.
|
||||||
|
|
||||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||||
|
|||||||
@@ -405,13 +405,18 @@ ASSIGNED INDEX: ${buildIndex}
|
|||||||
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.
|
||||||
No challengers this run: the roll service was unreachable and no local
|
No challengers this run: the roll service was unreachable and no local
|
||||||
catalog exists. A sandboxed exec tool with no network access causes exactly
|
catalog exists. A sandboxed shell without network egress is the most common
|
||||||
this; before accepting degradation, rerun this command once through the
|
cause; before accepting degradation, rerun this command once through the
|
||||||
harness's network-enabled command tool. A sandboxed shell without network egress is the most common
|
harness's network-enabled or escalated command tool. When that rerun needs
|
||||||
cause: if this harness can rerun the command with network access granted,
|
an approval, state exactly what the approver must know: this script's only
|
||||||
do that once before proceeding. Otherwise proceed with the grounded
|
network contact is one GET to https://impeccable.style/api/roll whose query
|
||||||
candidates alone; the assignment
|
carries scope, mode, an eight-hex seed key, and a re-roll counter; no
|
||||||
above still applies at full strength. Tell the user plainly that this roll
|
project files, prompts, code, or conversation context are transmitted, and
|
||||||
|
nothing is written. An approval request naming that URL and payload judges
|
||||||
|
the real action; a bare "run with network" invites rejection for contacting
|
||||||
|
an unspecified domain. If the rerun is still refused, proceed with the
|
||||||
|
grounded candidates alone; the assignment above still applies at full
|
||||||
|
strength. Tell the user plainly that this roll
|
||||||
ran degraded, with no challengers and no quality-bar boards; do not present
|
ran degraded, with no challengers and no quality-bar boards; do not present
|
||||||
the outcome as a full roll. A degraded roll changes the cards, not the
|
the outcome as a full roll. A degraded roll changes the cards, not the
|
||||||
channel: when a browser can open, present the direction on the decision page
|
channel: when a browser can open, present the direction on the decision page
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { loadContext, extractPlatform } from './context.mjs';
|
import { loadContext, extractPlatform } from './context.mjs';
|
||||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
import { readLatestSnapshotAcrossTargets } from './critique-storage.mjs';
|
||||||
|
|
||||||
/** Is there code here at all, or just context files / an empty repo? */
|
/** Is there code here at all, or just context files / an empty repo? */
|
||||||
function hasCode(cwd) {
|
function hasCode(cwd) {
|
||||||
@@ -34,23 +34,13 @@ function hasCode(cwd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The most recent critique snapshot across all targets. Filenames are
|
* Summarize the most recent critique snapshot across all targets.
|
||||||
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
|
|
||||||
* Parses the small frontmatter for score + P0/P1 counts.
|
|
||||||
*/
|
*/
|
||||||
function latestCritique(cwd) {
|
function latestCritique(cwd) {
|
||||||
try {
|
try {
|
||||||
const dir = getCritiqueDir(cwd);
|
const latest = readLatestSnapshotAcrossTargets({ cwd });
|
||||||
if (!fs.existsSync(dir)) return null;
|
if (!latest) return null;
|
||||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
|
const get = (key) => latest.meta[key] ?? null;
|
||||||
if (!files.length) return null;
|
|
||||||
const newest = files[files.length - 1];
|
|
||||||
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
|
|
||||||
const front = text.split('---')[1] || '';
|
|
||||||
const get = (k) => {
|
|
||||||
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
|
|
||||||
return m ? m[1].trim() : null;
|
|
||||||
};
|
|
||||||
const num = (v) => {
|
const num = (v) => {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
@@ -61,7 +51,7 @@ function latestCritique(cwd) {
|
|||||||
p0: num(get('p0')),
|
p0: num(get('p0')),
|
||||||
p1: num(get('p1')),
|
p1: num(get('p1')),
|
||||||
timestamp: get('timestamp'),
|
timestamp: get('timestamp'),
|
||||||
file: path.relative(cwd, path.join(dir, newest)),
|
file: path.relative(cwd, latest.path),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
* shape rather than the markdown block.
|
* shape rather than the markdown block.
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -1146,6 +1147,7 @@ async function cli() {
|
|||||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||||
parts.push(buildMissingTargetDirective());
|
parts.push(buildMissingTargetDirective());
|
||||||
}
|
}
|
||||||
|
appendImageToolsDirective(parts);
|
||||||
appendStalenessDirective(parts, ctx, cliOptions);
|
appendStalenessDirective(parts, ctx, cliOptions);
|
||||||
if (updateDirective) parts.push(updateDirective);
|
if (updateDirective) parts.push(updateDirective);
|
||||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||||
@@ -1180,6 +1182,7 @@ async function cli() {
|
|||||||
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
|
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
appendImageToolsDirective(parts);
|
||||||
appendStalenessDirective(parts, ctx, cliOptions);
|
appendStalenessDirective(parts, ctx, cliOptions);
|
||||||
if (!ctx.platform) {
|
if (!ctx.platform) {
|
||||||
// A `## Platform` section that names something we don't recognize (a
|
// A `## Platform` section that names something we don't recognize (a
|
||||||
@@ -1275,9 +1278,10 @@ function appendImageGenDirective(parts) {
|
|||||||
if (!process.env.OPENAI_API_KEY) return;
|
if (!process.env.OPENAI_API_KEY) return;
|
||||||
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
|
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
|
||||||
parts.push([
|
parts.push([
|
||||||
'IMAGE_GEN_AVAILABLE: An OpenAI key is present, so image generation works even without a harness-native image tool:',
|
'IMAGE_GEN_AVAILABLE: your harness-native image tool is always the first choice for generation; use it whenever one exists.',
|
||||||
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render).`,
|
'This environment also carries an OpenAI key as the fallback for harnesses with no native tool:',
|
||||||
'Prefer the harness-native image tool when one exists. Visualizing a direction before building it measurably strengthens the result.',
|
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render, and never reach for it when a native tool exists).`,
|
||||||
|
'Visualizing a direction before building it measurably strengthens the result.',
|
||||||
].join(' '));
|
].join(' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1332,6 +1336,19 @@ function appendDetectorFallback(parts, ctx) {
|
|||||||
// markdown already in memory, a bounded set of stats, or one of the small JSON
|
// markdown already in memory, a bounded set of stats, or one of the small JSON
|
||||||
// files the boot reads regardless. The deep pass (git drift, token divergence,
|
// files the boot reads regardless. The deep pass (git drift, token divergence,
|
||||||
// cross-workspace sweep) belongs to the doctor command, not to every session.
|
// cross-workspace sweep) belongs to the doctor command, not to every session.
|
||||||
|
// One boot-time probe replaces every session re-deriving its image toolchain:
|
||||||
|
// harnesses and OSes differ (cwebp, sips on macOS, magick, ffmpeg), and the
|
||||||
|
// agent should read this line instead of running command -v per image.
|
||||||
|
function appendImageToolsDirective(parts) {
|
||||||
|
const probe = process.platform === 'win32' ? 'where' : 'which';
|
||||||
|
const found = ['cwebp', 'sips', 'magick', 'ffmpeg'].filter((tool) => {
|
||||||
|
try { return spawnSync(probe, [tool], { stdio: 'ignore' }).status === 0; } catch { return false; }
|
||||||
|
});
|
||||||
|
parts.push(found.length
|
||||||
|
? `IMAGE_TOOLS: available image converters on this machine: ${found.join(', ')}. Use the first suitable one; never probe again this session.`
|
||||||
|
: 'IMAGE_TOOLS: no image converter found (cwebp, sips, magick, ffmpeg). Ship PNG output unconverted rather than probing per image.');
|
||||||
|
}
|
||||||
|
|
||||||
function appendStalenessDirective(parts, ctx, options) {
|
function appendStalenessDirective(parts, ctx, options) {
|
||||||
const projectRoot = ctx.projectRoot || process.cwd();
|
const projectRoot = ctx.projectRoot || process.cwd();
|
||||||
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
|
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
|
||||||
|
|||||||
@@ -105,28 +105,37 @@ function parseFrontmatter(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return all snapshot files for `slug`, sorted oldest → newest.
|
* Return snapshot files matching `suffix`, sorted oldest → newest.
|
||||||
*/
|
*/
|
||||||
function listSnapshotsForSlug(slug, cwd) {
|
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
|
||||||
|
|
||||||
|
function listSnapshots(suffix, cwd) {
|
||||||
const dir = getCritiqueDir(cwd);
|
const dir = getCritiqueDir(cwd);
|
||||||
if (!fs.existsSync(dir)) return [];
|
if (!fs.existsSync(dir)) return [];
|
||||||
const suffix = `__${slug}.md`;
|
|
||||||
return fs.readdirSync(dir)
|
return fs.readdirSync(dir)
|
||||||
.filter((f) => f.endsWith(suffix))
|
.filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
|
||||||
.sort()
|
.sort()
|
||||||
.map((f) => path.join(dir, f));
|
.map((f) => path.join(dir, f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readLatestSnapshotMatching(suffix, cwd) {
|
||||||
|
const filePath = listSnapshots(suffix, cwd).at(-1);
|
||||||
|
if (!filePath) return null;
|
||||||
|
const body = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return { path: filePath, body, meta: parseFrontmatter(body) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
||||||
* to find its fix backlog when the slug matches.
|
* to find its fix backlog when the slug matches.
|
||||||
*/
|
*/
|
||||||
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||||
const all = listSnapshotsForSlug(slug, cwd);
|
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
|
||||||
if (!all.length) return null;
|
}
|
||||||
const latest = all[all.length - 1];
|
|
||||||
const body = fs.readFileSync(latest, 'utf-8');
|
/** Return the most recent snapshot across all targets, or null. */
|
||||||
return { path: latest, body, meta: parseFrontmatter(body) };
|
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
|
||||||
|
return readLatestSnapshotMatching('.md', cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,7 +143,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
|||||||
* Critique appends a one-line trend to its output using this.
|
* Critique appends a one-line trend to its output using this.
|
||||||
*/
|
*/
|
||||||
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
||||||
const all = listSnapshotsForSlug(slug, cwd);
|
const all = listSnapshots(`__${slug}.md`, cwd);
|
||||||
const slice = all.slice(-limit);
|
const slice = all.slice(-limit);
|
||||||
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
|
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,10 +142,73 @@ function stripInlineYamlComment(s) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YAML double-quoted scalars process backslash escapes. Stripping the outer
|
||||||
|
// quotes without unescaping leaves them in place, so a nested font family like
|
||||||
|
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
|
||||||
|
// reaches allowedFonts as '\"ibm plex sans' and never matches the same family
|
||||||
|
// declared in CSS. Scanner instead of a regex: the escape set is small and the
|
||||||
|
// backslash handling stays readable.
|
||||||
|
// The full YAML 1.2 double-quote escape set (spec section 5.7).
|
||||||
|
const YAML_SIMPLE_ESCAPES = {
|
||||||
|
'0': '\0',
|
||||||
|
a: '\x07',
|
||||||
|
b: '\b',
|
||||||
|
t: '\t',
|
||||||
|
n: '\n',
|
||||||
|
v: '\v',
|
||||||
|
f: '\f',
|
||||||
|
r: '\r',
|
||||||
|
e: '\x1b',
|
||||||
|
' ': ' ',
|
||||||
|
'"': '"',
|
||||||
|
'/': '/',
|
||||||
|
'\\': '\\',
|
||||||
|
N: '\u0085',
|
||||||
|
_: '\u00a0',
|
||||||
|
L: '\u2028',
|
||||||
|
P: '\u2029',
|
||||||
|
};
|
||||||
|
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
|
||||||
|
|
||||||
|
function unescapeYamlDoubleQuoted(body) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
const ch = body[i];
|
||||||
|
if (ch !== '\\' || i === body.length - 1) {
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = body[i + 1];
|
||||||
|
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
|
||||||
|
out += YAML_SIMPLE_ESCAPES[next];
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
|
||||||
|
// literal rather than corrupting the rest of the scalar.
|
||||||
|
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
|
||||||
|
if (hexLen) {
|
||||||
|
const hex = body.slice(i + 2, i + 2 + hexLen);
|
||||||
|
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
|
||||||
|
if (codePoint >= 0 && codePoint <= 0x10ffff) {
|
||||||
|
out += String.fromCodePoint(codePoint);
|
||||||
|
i += 1 + hexLen;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function parseScalar(raw) {
|
function parseScalar(raw) {
|
||||||
const s = raw.trim();
|
const s = raw.trim();
|
||||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||||
return s.slice(1, -1);
|
return unescapeYamlDoubleQuoted(s.slice(1, -1));
|
||||||
|
}
|
||||||
|
// Single-quoted YAML escapes only the quote itself, by doubling it.
|
||||||
|
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
|
||||||
|
return s.slice(1, -1).split("''").join("'");
|
||||||
}
|
}
|
||||||
if (s === 'true') return true;
|
if (s === 'true') return true;
|
||||||
if (s === 'false') return false;
|
if (s === 'false') return false;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.m
|
|||||||
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
||||||
export {
|
export {
|
||||||
walkDir,
|
walkDir,
|
||||||
|
hasScannableExtension,
|
||||||
SCANNABLE_EXTENSIONS,
|
SCANNABLE_EXTENSIONS,
|
||||||
SKIP_DIRS,
|
SKIP_DIRS,
|
||||||
buildImportGraph,
|
buildImportGraph,
|
||||||
|
|||||||
@@ -41,6 +41,221 @@ function shouldRunPageAnalyzers(content, filePath) {
|
|||||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
||||||
|
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
|
||||||
|
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
|
||||||
|
|
||||||
|
function isInsideOpeningJsxTag(source) {
|
||||||
|
const tagStart = source.lastIndexOf('<');
|
||||||
|
if (tagStart === -1 || !/^<[A-Za-z][\w.:-]*/.test(source.slice(tagStart))) return false;
|
||||||
|
|
||||||
|
let quote = '';
|
||||||
|
for (let cursor = tagStart + 1; cursor < source.length; cursor++) {
|
||||||
|
const char = source[cursor];
|
||||||
|
if (quote) {
|
||||||
|
if (char === '\\') cursor++;
|
||||||
|
else if (char === quote) quote = '';
|
||||||
|
} else if (char === "'" || char === '"') {
|
||||||
|
quote = char;
|
||||||
|
} else if (char === '>') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blank JavaScript comments without moving any following source. Regex
|
||||||
|
* findings keep their original line numbers, while prose examples inside
|
||||||
|
* comments cannot masquerade as rendered markup.
|
||||||
|
*/
|
||||||
|
function stripJsComments(content, options = {}) {
|
||||||
|
let state = 'code';
|
||||||
|
let output = '';
|
||||||
|
let lastSignificant = '';
|
||||||
|
let previousSignificant = '';
|
||||||
|
let antePreviousSignificant = '';
|
||||||
|
let currentWord = '';
|
||||||
|
let currentWordPrefix = '';
|
||||||
|
let wordSeparated = false;
|
||||||
|
let regexCharClass = false;
|
||||||
|
let jsxExpressionDepth = 0;
|
||||||
|
let lastClosedBraceKind = '';
|
||||||
|
const braceKinds = [];
|
||||||
|
const templateExpressionDepths = [];
|
||||||
|
|
||||||
|
const braceKind = (startsJsxExpression = false) => (
|
||||||
|
!startsJsxExpression && (
|
||||||
|
!lastSignificant ||
|
||||||
|
lastSignificant === ')' ||
|
||||||
|
lastSignificant === ';' ||
|
||||||
|
lastSignificant === '}' ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
||||||
|
) ? 'block' : 'expression'
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordSignificant = (char) => {
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
wordSeparated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isWordChar = /[\w$]/.test(char);
|
||||||
|
if (isWordChar && (wordSeparated || !currentWord)) {
|
||||||
|
currentWord = '';
|
||||||
|
currentWordPrefix = lastSignificant;
|
||||||
|
} else if (!isWordChar) {
|
||||||
|
currentWordPrefix = '';
|
||||||
|
}
|
||||||
|
wordSeparated = false;
|
||||||
|
antePreviousSignificant = previousSignificant;
|
||||||
|
previousSignificant = lastSignificant;
|
||||||
|
lastSignificant = char;
|
||||||
|
currentWord = isWordChar ? currentWord + char : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
const char = content[i];
|
||||||
|
const next = content[i + 1];
|
||||||
|
|
||||||
|
if (state === 'line-comment') {
|
||||||
|
if (char === '\n') {
|
||||||
|
output += char;
|
||||||
|
state = 'code';
|
||||||
|
} else {
|
||||||
|
output += ' ';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'block-comment') {
|
||||||
|
if (char === '*' && next === '/') {
|
||||||
|
output += ' ';
|
||||||
|
i++;
|
||||||
|
state = 'code';
|
||||||
|
} else {
|
||||||
|
output += char === '\n' ? '\n' : ' ';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'regex') {
|
||||||
|
output += char;
|
||||||
|
if (char === '\\' && next) {
|
||||||
|
output += next;
|
||||||
|
i++;
|
||||||
|
} else if (char === '[') {
|
||||||
|
regexCharClass = true;
|
||||||
|
} else if (char === ']') {
|
||||||
|
regexCharClass = false;
|
||||||
|
} else if (char === '/' && !regexCharClass) {
|
||||||
|
state = 'code';
|
||||||
|
recordSignificant('/');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'template' && char === '$' && next === '{') {
|
||||||
|
output += '${';
|
||||||
|
i++;
|
||||||
|
recordSignificant('$');
|
||||||
|
recordSignificant('{');
|
||||||
|
templateExpressionDepths.push(1);
|
||||||
|
braceKinds.push('expression');
|
||||||
|
if (jsxExpressionDepth) jsxExpressionDepth++;
|
||||||
|
state = 'code';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== 'code') {
|
||||||
|
output += char;
|
||||||
|
if (char === '\\' && next) {
|
||||||
|
output += next;
|
||||||
|
i++;
|
||||||
|
} else if (
|
||||||
|
(state === 'single-quote' && char === "'") ||
|
||||||
|
(state === 'double-quote' && char === '"') ||
|
||||||
|
(state === 'template' && char === '`')
|
||||||
|
) {
|
||||||
|
state = 'code';
|
||||||
|
recordSignificant(char);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsxUrlSeparator = options.jsx && char === '/' && next === '/' &&
|
||||||
|
jsxExpressionDepth === 0 &&
|
||||||
|
(output.endsWith('http:') ||
|
||||||
|
output.endsWith('https:') ||
|
||||||
|
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1)) &&
|
||||||
|
/^[\w.-]+\.[A-Za-z]{2,}(?=[:/?#\s<]|$)/.test(content.slice(i + 2))));
|
||||||
|
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
||||||
|
previousSignificant === lastSignificant &&
|
||||||
|
antePreviousSignificant !== lastSignificant;
|
||||||
|
if (char === '/' && next === '/' && jsxUrlSeparator) {
|
||||||
|
output += '//';
|
||||||
|
i++;
|
||||||
|
recordSignificant('/');
|
||||||
|
recordSignificant('/');
|
||||||
|
} else if (char === '/' && next === '/') {
|
||||||
|
output += ' ';
|
||||||
|
i++;
|
||||||
|
state = 'line-comment';
|
||||||
|
} else if (char === '/' && next === '*') {
|
||||||
|
output += ' ';
|
||||||
|
i++;
|
||||||
|
state = 'block-comment';
|
||||||
|
} else if (templateExpressionDepths.length && char === '{') {
|
||||||
|
output += char;
|
||||||
|
templateExpressionDepths[templateExpressionDepths.length - 1]++;
|
||||||
|
braceKinds.push(braceKind());
|
||||||
|
if (jsxExpressionDepth) jsxExpressionDepth++;
|
||||||
|
recordSignificant(char);
|
||||||
|
} else if (templateExpressionDepths.length && char === '}') {
|
||||||
|
output += char;
|
||||||
|
const depthIndex = templateExpressionDepths.length - 1;
|
||||||
|
templateExpressionDepths[depthIndex]--;
|
||||||
|
lastClosedBraceKind = braceKinds.pop() || '';
|
||||||
|
if (jsxExpressionDepth) jsxExpressionDepth--;
|
||||||
|
recordSignificant(char);
|
||||||
|
if (templateExpressionDepths[depthIndex] === 0) {
|
||||||
|
templateExpressionDepths.pop();
|
||||||
|
state = 'template';
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
char === '/' &&
|
||||||
|
(!lastSignificant ||
|
||||||
|
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
||||||
|
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
||||||
|
) {
|
||||||
|
output += char;
|
||||||
|
state = 'regex';
|
||||||
|
regexCharClass = false;
|
||||||
|
} else {
|
||||||
|
output += char;
|
||||||
|
const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 &&
|
||||||
|
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1, -1)) ||
|
||||||
|
isInsideOpeningJsxTag(output.slice(0, -1)));
|
||||||
|
if (char === '{') braceKinds.push(braceKind(startsJsxExpression));
|
||||||
|
else if (char === '}') lastClosedBraceKind = braceKinds.pop() || '';
|
||||||
|
if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth++;
|
||||||
|
else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth--;
|
||||||
|
recordSignificant(char);
|
||||||
|
if (char === "'") state = 'single-quote';
|
||||||
|
else if (char === '"') state = 'double-quote';
|
||||||
|
else if (char === '`') state = 'template';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCssComments(content) {
|
||||||
|
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
|
||||||
|
}
|
||||||
|
|
||||||
function firstOverusedGoogleFont(text) {
|
function firstOverusedGoogleFont(text) {
|
||||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||||
}
|
}
|
||||||
@@ -528,18 +743,198 @@ function extractStyleBlocks(content, ext) {
|
|||||||
|
|
||||||
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
||||||
|
|
||||||
|
function findQuotedStringEnd(content, start, quote) {
|
||||||
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||||
|
if (content[cursor] === '\\') cursor++;
|
||||||
|
else if (content[cursor] === quote) return cursor;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRegexLiteralEnd(content, start) {
|
||||||
|
let inCharacterClass = false;
|
||||||
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||||
|
const char = content[cursor];
|
||||||
|
if (char === '\\') {
|
||||||
|
cursor++;
|
||||||
|
} else if (char === '[') {
|
||||||
|
inCharacterClass = true;
|
||||||
|
} else if (char === ']') {
|
||||||
|
inCharacterClass = false;
|
||||||
|
} else if (char === '/' && !inCharacterClass) {
|
||||||
|
while (/[A-Za-z]/.test(content[cursor + 1] || '')) cursor++;
|
||||||
|
return cursor;
|
||||||
|
} else if (char === '\n' || char === '\r') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTemplateExpressionEnd(content, start) {
|
||||||
|
let depth = 1;
|
||||||
|
let lastSignificant = '';
|
||||||
|
let previousSignificant = '';
|
||||||
|
let antePreviousSignificant = '';
|
||||||
|
let currentWord = '';
|
||||||
|
let currentWordPrefix = '';
|
||||||
|
let wordSeparated = false;
|
||||||
|
let lastClosedBraceKind = '';
|
||||||
|
const braceKinds = [];
|
||||||
|
|
||||||
|
const braceKind = () => (
|
||||||
|
lastSignificant === ')' ||
|
||||||
|
lastSignificant === ';' ||
|
||||||
|
lastSignificant === '}' ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
||||||
|
? 'block'
|
||||||
|
: 'expression'
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordSignificant = (char) => {
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
wordSeparated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isWordChar = /[\w$]/.test(char);
|
||||||
|
if (isWordChar && (wordSeparated || !currentWord)) {
|
||||||
|
currentWord = '';
|
||||||
|
currentWordPrefix = lastSignificant;
|
||||||
|
} else if (!isWordChar) {
|
||||||
|
currentWordPrefix = '';
|
||||||
|
}
|
||||||
|
wordSeparated = false;
|
||||||
|
antePreviousSignificant = previousSignificant;
|
||||||
|
previousSignificant = lastSignificant;
|
||||||
|
lastSignificant = char;
|
||||||
|
currentWord = isWordChar ? currentWord + char : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let cursor = start; cursor < content.length; cursor++) {
|
||||||
|
const char = content[cursor];
|
||||||
|
const next = content[cursor + 1];
|
||||||
|
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
||||||
|
previousSignificant === lastSignificant &&
|
||||||
|
antePreviousSignificant !== lastSignificant;
|
||||||
|
if (char === "'" || char === '"') {
|
||||||
|
cursor = findQuotedStringEnd(content, cursor, char);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
recordSignificant(')');
|
||||||
|
} else if (char === '/' && next === '/') {
|
||||||
|
const lineEnd = content.indexOf('\n', cursor + 2);
|
||||||
|
if (lineEnd === -1) return -1;
|
||||||
|
cursor = lineEnd;
|
||||||
|
} else if (char === '/' && next === '*') {
|
||||||
|
const commentEnd = content.indexOf('*/', cursor + 2);
|
||||||
|
if (commentEnd === -1) return -1;
|
||||||
|
cursor = commentEnd + 1;
|
||||||
|
} else if (
|
||||||
|
char === '/' &&
|
||||||
|
(!lastSignificant ||
|
||||||
|
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
||||||
|
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
||||||
|
) {
|
||||||
|
cursor = findRegexLiteralEnd(content, cursor);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
recordSignificant(')');
|
||||||
|
} else if (char === '`') {
|
||||||
|
cursor = findTemplateLiteralEnd(content, cursor);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
recordSignificant(')');
|
||||||
|
} else if (char === '{') {
|
||||||
|
depth++;
|
||||||
|
braceKinds.push(braceKind());
|
||||||
|
recordSignificant(char);
|
||||||
|
} else if (char === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return cursor;
|
||||||
|
lastClosedBraceKind = braceKinds.pop() || '';
|
||||||
|
recordSignificant(char);
|
||||||
|
} else {
|
||||||
|
recordSignificant(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTemplateLiteralEnd(content, start) {
|
||||||
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||||
|
const char = content[cursor];
|
||||||
|
if (char === '\\') {
|
||||||
|
cursor++;
|
||||||
|
} else if (char === '`') {
|
||||||
|
return cursor;
|
||||||
|
} else if (char === '$' && content[cursor + 1] === '{') {
|
||||||
|
cursor = findTemplateExpressionEnd(content, cursor + 2);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCSSinJSTemplates(content) {
|
||||||
|
const templates = [];
|
||||||
|
const tagRe = /\b(?:styled(?:\.\w+|\([^)]+\))|css)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = tagRe.exec(content)) !== null) {
|
||||||
|
let cursor = match.index + match[0].length;
|
||||||
|
while (/\s/.test(content[cursor] || '')) cursor++;
|
||||||
|
|
||||||
|
if (content[cursor] === '<') {
|
||||||
|
let depth = 0;
|
||||||
|
while (cursor < content.length) {
|
||||||
|
const char = content[cursor];
|
||||||
|
if (char === '<') depth++;
|
||||||
|
else if (char === '>' && content[cursor - 1] !== '=') depth--;
|
||||||
|
cursor++;
|
||||||
|
if (depth === 0) break;
|
||||||
|
}
|
||||||
|
if (depth !== 0) continue;
|
||||||
|
while (/\s/.test(content[cursor] || '')) cursor++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content[cursor] !== '`') continue;
|
||||||
|
const contentStart = cursor + 1;
|
||||||
|
cursor = findTemplateLiteralEnd(content, cursor);
|
||||||
|
if (cursor === -1) continue;
|
||||||
|
|
||||||
|
templates.push({
|
||||||
|
tagStart: match.index,
|
||||||
|
contentStart,
|
||||||
|
contentEnd: cursor,
|
||||||
|
});
|
||||||
|
tagRe.lastIndex = cursor + 1;
|
||||||
|
}
|
||||||
|
return templates;
|
||||||
|
}
|
||||||
|
|
||||||
function extractCSSinJS(content, ext) {
|
function extractCSSinJS(content, ext) {
|
||||||
ext = ext.toLowerCase();
|
ext = ext.toLowerCase();
|
||||||
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
||||||
const blocks = [];
|
return findCSSinJSTemplates(content).map((template) => {
|
||||||
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
|
const before = content.substring(0, template.tagStart);
|
||||||
let m;
|
|
||||||
while ((m = re.exec(content)) !== null) {
|
|
||||||
const before = content.substring(0, m.index);
|
|
||||||
const startLine = before.split('\n').length;
|
const startLine = before.split('\n').length;
|
||||||
blocks.push({ content: m[1], startLine });
|
return {
|
||||||
|
content: content.slice(template.contentStart, template.contentEnd),
|
||||||
|
startLine,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCssInJsComments(content, ext) {
|
||||||
|
if (!CSS_IN_JS_EXTENSIONS.has(ext.toLowerCase())) return content;
|
||||||
|
const templates = findCSSinJSTemplates(content);
|
||||||
|
let output = '';
|
||||||
|
let cursor = 0;
|
||||||
|
for (const template of templates) {
|
||||||
|
output += content.slice(cursor, template.contentStart);
|
||||||
|
output += stripCssComments(content.slice(template.contentStart, template.contentEnd));
|
||||||
|
cursor = template.contentEnd;
|
||||||
}
|
}
|
||||||
return blocks;
|
return output + content.slice(cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
|
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
|
||||||
@@ -627,8 +1022,12 @@ function runTextContentAnalyzers(content, filePath, options = {}) {
|
|||||||
function detectText(content, filePath, options = {}) {
|
function detectText(content, filePath, options = {}) {
|
||||||
const profile = options?.profile;
|
const profile = options?.profile;
|
||||||
const findings = [];
|
const findings = [];
|
||||||
const lines = content.split('\n');
|
|
||||||
const ext = extFromFilePath(filePath);
|
const ext = extFromFilePath(filePath);
|
||||||
|
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
|
||||||
|
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
|
||||||
|
}) : content;
|
||||||
|
const source = stripCssInJsComments(commentStrippedSource, ext);
|
||||||
|
const lines = source.split('\n');
|
||||||
|
|
||||||
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
||||||
// Enable block context for CSS files where related properties span multiple lines
|
// Enable block context for CSS files where related properties span multiple lines
|
||||||
@@ -661,8 +1060,8 @@ function detectText(content, filePath, options = {}) {
|
|||||||
phase: 'source',
|
phase: 'source',
|
||||||
ruleId: 'codex-grid-background',
|
ruleId: 'codex-grid-background',
|
||||||
target: filePath,
|
target: filePath,
|
||||||
}, () => scanCssTextForGridBackground(content).map(hit => {
|
}, () => scanCssTextForGridBackground(source).map(hit => {
|
||||||
const line = content.substring(0, hit.index).split('\n').length;
|
const line = source.substring(0, hit.index).split('\n').length;
|
||||||
return finding('codex-grid-background', filePath, hit.snippet, line);
|
return finding('codex-grid-background', filePath, hit.snippet, line);
|
||||||
})));
|
})));
|
||||||
|
|
||||||
@@ -698,16 +1097,17 @@ function detectText(content, filePath, options = {}) {
|
|||||||
phase: 'extract',
|
phase: 'extract',
|
||||||
ruleId: 'css-in-js',
|
ruleId: 'css-in-js',
|
||||||
target: filePath,
|
target: filePath,
|
||||||
}, () => extractCSSinJS(content, ext))
|
}, () => extractCSSinJS(source, ext))
|
||||||
: extractCSSinJS(content, ext);
|
: extractCSSinJS(source, ext);
|
||||||
for (const block of cssJsBlocks) {
|
for (const block of cssJsBlocks) {
|
||||||
const blockLines = block.content.split('\n');
|
const blockContent = stripCssComments(block.content);
|
||||||
|
const blockLines = blockContent.split('\n');
|
||||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||||
profile,
|
profile,
|
||||||
phase: 'css-in-js',
|
phase: 'css-in-js',
|
||||||
}));
|
}));
|
||||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
|
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 1));
|
||||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
|
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.designSystem) {
|
if (options?.designSystem) {
|
||||||
|
|||||||
@@ -26,11 +26,26 @@ const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
|
|||||||
const SCANNABLE_EXTENSIONS = new Set([
|
const SCANNABLE_EXTENSIONS = new Set([
|
||||||
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
||||||
'.jsx', '.tsx', '.js', '.ts',
|
'.jsx', '.tsx', '.js', '.ts',
|
||||||
'.vue', '.svelte', '.astro',
|
'.vue', '.svelte', '.astro', '.blade.php',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
||||||
|
|
||||||
|
function hasScannableExtension(filename) {
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
|
||||||
|
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||||
|
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORT_SPECIFIER_PATTERNS = [
|
||||||
|
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
|
||||||
|
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
|
||||||
|
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||||
|
];
|
||||||
|
|
||||||
function walkDir(dir) {
|
function walkDir(dir) {
|
||||||
const files = [];
|
const files = [];
|
||||||
let entries;
|
let entries;
|
||||||
@@ -40,7 +55,7 @@ function walkDir(dir) {
|
|||||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||||
const full = path.join(dir, entry.name);
|
const full = path.join(dir, entry.name);
|
||||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||||
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
|
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
@@ -75,26 +90,11 @@ function buildImportGraph(files) {
|
|||||||
const dir = path.dirname(file);
|
const dir = path.dirname(file);
|
||||||
const imports = new Set();
|
const imports = new Set();
|
||||||
|
|
||||||
// ES imports: import ... from '...' and import '...'
|
for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
|
||||||
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
|
for (const match of content.matchAll(pattern)) {
|
||||||
let m;
|
const resolved = resolveImport(match[1], dir, fileSet);
|
||||||
while ((m = esRe.exec(content)) !== null) {
|
if (resolved) imports.add(resolved);
|
||||||
const resolved = resolveImport(m[1], dir, fileSet);
|
}
|
||||||
if (resolved) imports.add(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSS @import
|
|
||||||
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
|
|
||||||
while ((m = cssRe.exec(content)) !== null) {
|
|
||||||
const resolved = resolveImport(m[1], dir, fileSet);
|
|
||||||
if (resolved) imports.add(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SCSS @use / @forward
|
|
||||||
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
|
|
||||||
while ((m = scssRe.exec(content)) !== null) {
|
|
||||||
const resolved = resolveImport(m[1], dir, fileSet);
|
|
||||||
if (resolved) imports.add(resolved);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
graph.set(file, imports);
|
graph.set(file, imports);
|
||||||
@@ -203,6 +203,7 @@ export {
|
|||||||
SKIP_DIRS,
|
SKIP_DIRS,
|
||||||
SCANNABLE_EXTENSIONS,
|
SCANNABLE_EXTENSIONS,
|
||||||
HTML_EXTENSIONS,
|
HTML_EXTENSIONS,
|
||||||
|
hasScannableExtension,
|
||||||
walkDir,
|
walkDir,
|
||||||
resolveImport,
|
resolveImport,
|
||||||
buildImportGraph,
|
buildImportGraph,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* node hook-admin.mjs off # set enabled: false
|
* node hook-admin.mjs off # set enabled: false
|
||||||
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
|
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
|
||||||
* node hook-admin.mjs ignore-rule overused-font --all-values
|
* node hook-admin.mjs ignore-rule overused-font --all-values
|
||||||
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
|
* node hook-admin.mjs ignore-file <glob> [--shared|--local] # append to ignoreFiles
|
||||||
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
||||||
* node hook-admin.mjs ignore-value <rule> <value> --local
|
* node hook-admin.mjs ignore-value <rule> <value> --local
|
||||||
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
|
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
|
||||||
@@ -166,7 +166,7 @@ function readRawConfigFile(filePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
|
||||||
|
|
||||||
function hookSection(unified) {
|
function hookSection(unified) {
|
||||||
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
|
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
|
||||||
@@ -200,6 +200,15 @@ function stripDetectorKeys(raw) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickDetectorKeys(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||||
|
const out = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Write hook runtime config under `hook`, leaving detector filters in
|
// Write hook runtime config under `hook`, leaving detector filters in
|
||||||
// `detector` and preserving sibling keys such as updateCheck.
|
// `detector` and preserving sibling keys such as updateCheck.
|
||||||
function writeHookConfig(cwd, hookConfig, opts = {}) {
|
function writeHookConfig(cwd, hookConfig, opts = {}) {
|
||||||
@@ -207,10 +216,19 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
|
|||||||
if (opts.local) ensureHookGitExcludes(cwd);
|
if (opts.local) ensureHookGitExcludes(cwd);
|
||||||
const existingRaw = readRawConfigFile(filePath).raw;
|
const existingRaw = readRawConfigFile(filePath).raw;
|
||||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||||
const existingHook = stripDetectorKeys(hookSection(existing));
|
const existingHookSection = hookSection(existing);
|
||||||
|
const existingHook = stripDetectorKeys(existingHookSection);
|
||||||
|
const legacyDetector = pickDetectorKeys(existingHookSection);
|
||||||
// Merge over the existing hook object so fields the merge helpers don't manage
|
// Merge over the existing hook object so fields the merge helpers don't manage
|
||||||
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
|
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
|
||||||
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
||||||
|
if (Object.keys(legacyDetector).length > 0) {
|
||||||
|
const existingDetector = detectorSection(existing) || {};
|
||||||
|
next.detector = {
|
||||||
|
...existingDetector,
|
||||||
|
...mergeDetectorConfig(existingDetector, mergeDetectorConfig(legacyDetector)),
|
||||||
|
};
|
||||||
|
}
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
||||||
return filePath;
|
return filePath;
|
||||||
@@ -222,10 +240,14 @@ function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
|
|||||||
const existingRaw = readRawConfigFile(filePath).raw;
|
const existingRaw = readRawConfigFile(filePath).raw;
|
||||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||||
const nextHook = stripDetectorKeys(hookSection(existing));
|
const nextHook = stripDetectorKeys(hookSection(existing));
|
||||||
const existingDetector = mergeDetectorConfig(detectorSection(existing));
|
const existingDetectorSection = detectorSection(existing) || {};
|
||||||
|
const existingDetector = mergeDetectorConfig(existingDetectorSection);
|
||||||
const next = {
|
const next = {
|
||||||
...existing,
|
...existing,
|
||||||
detector: mergeDetectorConfig(detectorConfig, existingDetector),
|
detector: {
|
||||||
|
...existingDetectorSection,
|
||||||
|
...mergeDetectorConfig(detectorConfig, existingDetector),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
|
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
|
||||||
else delete next.hook;
|
else delete next.hook;
|
||||||
@@ -259,12 +281,18 @@ function mergeDetectorConfig(existing, seed = null) {
|
|||||||
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
|
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
|
||||||
out.designSystem = { ...seed.designSystem };
|
out.designSystem = { ...seed.designSystem };
|
||||||
}
|
}
|
||||||
|
if (seed?.advisoryRules === 'include' || seed?.advisoryRules === 'exclude') {
|
||||||
|
out.advisoryRules = seed.advisoryRules;
|
||||||
|
}
|
||||||
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
|
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
|
||||||
out.designSystem = {
|
out.designSystem = {
|
||||||
...(out.designSystem || {}),
|
...(out.designSystem || {}),
|
||||||
enabled: base.designSystem.enabled === false ? false : true,
|
enabled: base.designSystem.enabled === false ? false : true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (base.advisoryRules === 'include' || base.advisoryRules === 'exclude') {
|
||||||
|
out.advisoryRules = base.advisoryRules;
|
||||||
|
}
|
||||||
if (Array.isArray(base.ignoreRules)) {
|
if (Array.isArray(base.ignoreRules)) {
|
||||||
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
|
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
|
||||||
}
|
}
|
||||||
@@ -558,12 +586,44 @@ function addIgnoreRule(cwd, args) {
|
|||||||
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
|
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addIgnoreFile(cwd, glob) {
|
function parseIgnoreFileArgs(args) {
|
||||||
|
const positionals = [];
|
||||||
|
let shared = false;
|
||||||
|
let local = false;
|
||||||
|
|
||||||
|
for (const raw of args) {
|
||||||
|
const arg = String(raw || '');
|
||||||
|
if (arg === '--shared') {
|
||||||
|
shared = true;
|
||||||
|
} else if (arg === '--local') {
|
||||||
|
local = true;
|
||||||
|
} else if (arg === '--reason' || arg.startsWith('--reason=')) {
|
||||||
|
throw new Error('--reason is not supported for ignore-file because detector.ignoreFiles stores globs only; use ignore-value when a documented rule-specific exception fits');
|
||||||
|
} else if (arg.startsWith('--')) {
|
||||||
|
throw new Error(`Unknown ignore-file flag: ${arg}`);
|
||||||
|
} else {
|
||||||
|
positionals.push(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shared && local) throw new Error('Pass only one scope flag: --shared or --local');
|
||||||
|
if (positionals.length > 1) throw new Error('Pass exactly one glob to ignore-file');
|
||||||
|
|
||||||
|
return {
|
||||||
|
glob: positionals[0],
|
||||||
|
local,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIgnoreFile(cwd, args) {
|
||||||
|
const parsed = parseIgnoreFileArgs(args);
|
||||||
|
const glob = parsed.glob;
|
||||||
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
|
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
|
||||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
|
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local: parsed.local }));
|
||||||
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
||||||
writeDetectorConfig(cwd, config);
|
const target = writeDetectorConfig(cwd, config, { local: parsed.local });
|
||||||
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
|
const scope = parsed.local ? 'local detector.ignoreFiles' : 'shared detector.ignoreFiles';
|
||||||
|
return `Added "${glob}" to ${scope} (${path.relative(cwd, target) || target}). Current: ${config.ignoreFiles.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
|
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
|
||||||
@@ -727,7 +787,7 @@ function main() {
|
|||||||
case 'on': out = setEnabled(cwd, true); break;
|
case 'on': out = setEnabled(cwd, true); break;
|
||||||
case 'off': out = setEnabled(cwd, false); break;
|
case 'off': out = setEnabled(cwd, false); break;
|
||||||
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
|
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
|
||||||
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
|
case 'ignore-file': out = addIgnoreFile(cwd, rest); break;
|
||||||
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
|
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
|
||||||
case 'reset': out = reset(cwd); break;
|
case 'reset': out = reset(cwd); break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
designSystemOptions,
|
designSystemOptions,
|
||||||
filterFindings,
|
filterFindings,
|
||||||
isNativePlatform,
|
isNativePlatform,
|
||||||
|
isScanTargetInsideProject,
|
||||||
loadDetector,
|
loadDetector,
|
||||||
matchConfiguredExtension,
|
matchConfiguredExtension,
|
||||||
matchesAnyGlob,
|
matchesAnyGlob,
|
||||||
@@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readExistingProjectFile(filePath, cwd) {
|
function readExistingProjectFile(filePath, cwd) {
|
||||||
if (!isInsideProject(filePath, cwd)) return null;
|
if (!isScanTargetInsideProject(filePath, cwd)) return null;
|
||||||
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
|
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
@@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) {
|
|||||||
const source = shellCopyPaths(command)?.source;
|
const source = shellCopyPaths(command)?.source;
|
||||||
if (!source) return '';
|
if (!source) return '';
|
||||||
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
|
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
|
||||||
if (!isInsideProject(sourcePath, cwd)) return '';
|
if (!isScanTargetInsideProject(sourcePath, cwd)) return '';
|
||||||
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
|
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(sourcePath);
|
const stat = fs.statSync(sourcePath);
|
||||||
@@ -328,15 +329,6 @@ function relativePath(filePath, cwd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInsideProject(filePath, cwd) {
|
|
||||||
try {
|
|
||||||
const rel = path.relative(cwd, filePath);
|
|
||||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||||
@@ -414,7 +406,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
|
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
|
||||||
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
||||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||||
|
|
||||||
|
|||||||
@@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve a path to its canonical (symlink-free) form. When the path does
|
||||||
|
// not exist yet — the before-edit hook gates proposed Writes — canonicalize
|
||||||
|
// the nearest existing ancestor and re-append the remainder, so a new file
|
||||||
|
// under a symlinked root still compares equal to its canonical project.
|
||||||
|
// Memoized: the hook runs as a fresh process per tool event, so the cache
|
||||||
|
// amounts to once-per-event work — the scan loops re-check the same project
|
||||||
|
// root for every target file. The cap only matters to long-lived importers
|
||||||
|
// like the test runner.
|
||||||
|
const canonicalPathCache = new Map();
|
||||||
|
const CANONICAL_PATH_CACHE_MAX = 1024;
|
||||||
|
|
||||||
|
function canonicalPath(p) {
|
||||||
|
const resolved = path.resolve(p);
|
||||||
|
if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved);
|
||||||
|
let canonical = resolved;
|
||||||
|
let dir = resolved;
|
||||||
|
const tail = [];
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir);
|
||||||
|
break;
|
||||||
|
} catch { /* keep climbing */ }
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) break;
|
||||||
|
tail.unshift(path.basename(dir));
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear();
|
||||||
|
canonicalPathCache.set(resolved, canonical);
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Containment gate shared by the before-edit hook and both scan passes. A
|
||||||
|
// session routinely touches files that belong to no project or to a
|
||||||
|
// different one — harness scratchpad dirs under the system temp root,
|
||||||
|
// sibling checkouts, one-off throwaway HTML — and findings against those are
|
||||||
|
// judged with THIS project's config and DESIGN.md palette, which is never
|
||||||
|
// right. Skip them (audit reason: outside-project). Paths are canonicalized
|
||||||
|
// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the
|
||||||
|
// comparison.
|
||||||
|
export function isScanTargetInsideProject(filePath, projectCwd) {
|
||||||
|
if (!filePath || !projectCwd) return false;
|
||||||
|
return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd));
|
||||||
|
}
|
||||||
|
|
||||||
export function parseStaticStyleImports(content, fromFile, projectCwd) {
|
export function parseStaticStyleImports(content, fromFile, projectCwd) {
|
||||||
if (!content || typeof content !== 'string') return [];
|
if (!content || typeof content !== 'string') return [];
|
||||||
const dir = path.dirname(fromFile);
|
const dir = path.dirname(fromFile);
|
||||||
@@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|||||||
lastSkip = 'file-missing';
|
lastSkip = 'file-missing';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!isScanTargetInsideProject(filePath, projectCwd)) {
|
||||||
|
lastSkip = 'outside-project';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
|
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
|
||||||
if (maxFileBytes > 0) {
|
if (maxFileBytes > 0) {
|
||||||
@@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
|||||||
const relForMatch = relativize(filePath, projectCwd);
|
const relForMatch = relativize(filePath, projectCwd);
|
||||||
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
|
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
|
||||||
if (!fs.existsSync(filePath)) continue;
|
if (!fs.existsSync(filePath)) continue;
|
||||||
|
// Caches written before this gate existed can still hold out-of-project
|
||||||
|
// paths, so the Stop pass re-checks containment rather than trusting
|
||||||
|
// the per-edit pass to have filtered them.
|
||||||
|
if (!isScanTargetInsideProject(filePath, projectCwd)) continue;
|
||||||
|
|
||||||
scanned += 1;
|
scanned += 1;
|
||||||
let content = '';
|
let content = '';
|
||||||
|
|||||||
@@ -50,9 +50,36 @@ export function normalizeConceptForm(value) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateConceptEntry(concept, { existingForms = new Map() } = {}) {
|
export function validateConceptEntry(concept, { existingForms = new Map(), axes = null } = {}) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const id = concept?.id || '(unknown)';
|
const id = concept?.id || '(unknown)';
|
||||||
|
|
||||||
|
// Recorded aesthetic axis values. Optional, and absent means the value is
|
||||||
|
// inferred from the system rules instead. Some axes cannot be inferred at all:
|
||||||
|
// depth's keyword probe matched worlds that said "no cast shadow anywhere",
|
||||||
|
// and motion and colour strategy describe properties the rules never state, so
|
||||||
|
// a wave that assigns those has to record them or the assignment is lost.
|
||||||
|
// Validated against the axes definition when the caller supplies it, because a
|
||||||
|
// typo would read as "unrecorded" and silently fall back to a probe that is
|
||||||
|
// known not to work.
|
||||||
|
if (concept?.axes !== undefined && concept.axes !== null) {
|
||||||
|
if (typeof concept.axes !== 'object' || Array.isArray(concept.axes)) {
|
||||||
|
errors.push(`concept ${id} axes must be an object of axis id to value id`);
|
||||||
|
} else if (axes) {
|
||||||
|
const byId = new Map((axes.axes || []).map(axis => [axis.id, axis]));
|
||||||
|
for (const [axisId, valueId] of Object.entries(concept.axes)) {
|
||||||
|
const axis = byId.get(axisId);
|
||||||
|
if (!axis) {
|
||||||
|
errors.push(`concept ${id} names unknown axis "${axisId}"`);
|
||||||
|
} else if (!(axis.values || []).some(value => value.id === valueId)) {
|
||||||
|
errors.push(
|
||||||
|
`concept ${id} axis "${axisId}" has unknown value "${valueId}" `
|
||||||
|
+ `(expected one of ${(axis.values || []).map(v => v.id).join(', ')})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
|
||||||
errors.push(`invalid concept id: ${String(concept?.id)}`);
|
errors.push(`invalid concept id: ${String(concept?.id)}`);
|
||||||
}
|
}
|
||||||
@@ -82,6 +109,18 @@ export function validateConceptEntry(concept, { existingForms = new Map() } = {}
|
|||||||
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||||
errors.push(`concept ${id} must have exactly three structural tags`);
|
errors.push(`concept ${id} must have exactly three structural tags`);
|
||||||
}
|
}
|
||||||
|
// The slop this world in particular is at risk of. Optional, because 541
|
||||||
|
// entries predate it and none of them are wrong for lacking it. A world built
|
||||||
|
// from posters is at risk of shouting and one built from instruments is at
|
||||||
|
// risk of dead greys; a global detector cannot know which, and the author can.
|
||||||
|
if (concept?.avoid !== undefined) {
|
||||||
|
if (!Array.isArray(concept.avoid)
|
||||||
|
|| concept.avoid.length < 2
|
||||||
|
|| concept.avoid.length > 3
|
||||||
|
|| concept.avoid.some(item => typeof item !== 'string' || item.trim().length < 12 || item.trim().length > 160)) {
|
||||||
|
errors.push(`concept ${id} avoid must be two or three negations of 12–160 characters`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!Array.isArray(concept?.system)
|
if (!Array.isArray(concept?.system)
|
||||||
|| concept.system.length !== SYSTEM_PREFIXES.length
|
|| concept.system.length !== SYSTEM_PREFIXES.length
|
||||||
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
||||||
|
|||||||
@@ -2,15 +2,20 @@
|
|||||||
// the live-mode design-system panel can render. Deterministic, dependency-free.
|
// the live-mode design-system panel can render. Deterministic, dependency-free.
|
||||||
//
|
//
|
||||||
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
|
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
|
||||||
// (prose with six canonical H2 sections). When frontmatter is present, it's
|
// (prose with eight canonical H2 sections). When frontmatter is present, it's
|
||||||
// exposed on `model.frontmatter` alongside the prose-scraped sections;
|
// exposed on `model.frontmatter` alongside the prose-scraped sections;
|
||||||
// consumers can prefer frontmatter values and fall back to prose.
|
// consumers can prefer frontmatter values and fall back to prose.
|
||||||
|
|
||||||
|
// Array order is also match precedence: matchCanonicalSection's keyword-contained
|
||||||
|
// pass returns the first entry a heading contains, so reordering this changes
|
||||||
|
// which section an ambiguous heading resolves to.
|
||||||
const CANONICAL_SECTIONS = [
|
const CANONICAL_SECTIONS = [
|
||||||
'Overview',
|
'Overview',
|
||||||
'Colors',
|
'Colors',
|
||||||
'Typography',
|
'Typography',
|
||||||
|
'Layout',
|
||||||
'Elevation',
|
'Elevation',
|
||||||
|
'Shapes',
|
||||||
'Components',
|
'Components',
|
||||||
"Do's and Don'ts",
|
"Do's and Don'ts",
|
||||||
];
|
];
|
||||||
@@ -115,10 +120,71 @@ function stripInlineYamlComment(s) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YAML double-quoted scalars process backslash escapes. Stripping the outer
|
||||||
|
// quotes without unescaping leaves them in place, so a nested font family like
|
||||||
|
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
|
||||||
|
// keeps its literal backslashes and never matches the same family in CSS.
|
||||||
|
// The full YAML 1.2 double-quote escape set (spec section 5.7).
|
||||||
|
const YAML_SIMPLE_ESCAPES = {
|
||||||
|
'0': '\0',
|
||||||
|
a: '\x07',
|
||||||
|
b: '\b',
|
||||||
|
t: '\t',
|
||||||
|
n: '\n',
|
||||||
|
v: '\v',
|
||||||
|
f: '\f',
|
||||||
|
r: '\r',
|
||||||
|
e: '\x1b',
|
||||||
|
' ': ' ',
|
||||||
|
'"': '"',
|
||||||
|
'/': '/',
|
||||||
|
'\\': '\\',
|
||||||
|
N: '\u0085',
|
||||||
|
_: '\u00a0',
|
||||||
|
L: '\u2028',
|
||||||
|
P: '\u2029',
|
||||||
|
};
|
||||||
|
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
|
||||||
|
|
||||||
|
function unescapeYamlDoubleQuoted(body) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
const ch = body[i];
|
||||||
|
if (ch !== '\\' || i === body.length - 1) {
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = body[i + 1];
|
||||||
|
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
|
||||||
|
out += YAML_SIMPLE_ESCAPES[next];
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
|
||||||
|
// literal rather than corrupting the rest of the scalar.
|
||||||
|
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
|
||||||
|
if (hexLen) {
|
||||||
|
const hex = body.slice(i + 2, i + 2 + hexLen);
|
||||||
|
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
|
||||||
|
if (codePoint >= 0 && codePoint <= 0x10ffff) {
|
||||||
|
out += String.fromCodePoint(codePoint);
|
||||||
|
i += 1 + hexLen;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function parseScalar(raw) {
|
function parseScalar(raw) {
|
||||||
const s = raw.trim();
|
const s = raw.trim();
|
||||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||||
return s.slice(1, -1);
|
return unescapeYamlDoubleQuoted(s.slice(1, -1));
|
||||||
|
}
|
||||||
|
// Single-quoted YAML escapes only the quote itself, by doubling it.
|
||||||
|
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
|
||||||
|
return s.slice(1, -1).split("''").join("'");
|
||||||
}
|
}
|
||||||
if (s === 'true') return true;
|
if (s === 'true') return true;
|
||||||
if (s === 'false') return false;
|
if (s === 'false') return false;
|
||||||
@@ -330,17 +396,16 @@ function extractOverview(section) {
|
|||||||
if (!section) return null;
|
if (!section) return null;
|
||||||
const text = section.lines.join('\n');
|
const text = section.lines.join('\n');
|
||||||
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
|
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
|
||||||
const keyChars = [];
|
|
||||||
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
|
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
|
||||||
if (keyCharMatch) {
|
const keyChars = keyCharMatch
|
||||||
for (const line of keyCharMatch[1].split('\n')) {
|
? collectBullets(keyCharMatch[1].split('\n')).map((bullet) => stripBold(bullet.trim()))
|
||||||
const m = line.match(/^\s*[-*]\s+(.+)$/);
|
: [];
|
||||||
if (m) keyChars.push(stripBold(m[1].trim()));
|
const prose = keyCharMatch
|
||||||
}
|
? text.slice(0, keyCharMatch.index) + text.slice(keyCharMatch.index + keyCharMatch[0].length)
|
||||||
}
|
: text;
|
||||||
|
|
||||||
// Philosophy paragraphs: everything that isn't a rule header or key-char block
|
// Philosophy paragraphs: everything that isn't a rule header or key-char block
|
||||||
const paragraphs = collectParagraphs(section.lines).filter(
|
const paragraphs = collectParagraphs(prose.split('\n')).filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
!p.startsWith('**Creative North Star') &&
|
!p.startsWith('**Creative North Star') &&
|
||||||
!p.startsWith('**Key Characteristics')
|
!p.startsWith('**Key Characteristics')
|
||||||
@@ -602,11 +667,19 @@ function parseTypeBullet(bullet) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractElevation(section) {
|
function extractGuidance(section) {
|
||||||
if (!section) return null;
|
if (!section) return null;
|
||||||
const subs = splitSubsections(section.lines);
|
const subs = splitSubsections(section.lines);
|
||||||
|
return {
|
||||||
|
subtitle: section.subtitle,
|
||||||
|
description: collectParagraphs(subs[0].lines).join(' ') || null,
|
||||||
|
rules: extractNamedRules(section.lines),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const description = collectParagraphs(subs[0].lines).join(' ') || null;
|
function extractElevation(section) {
|
||||||
|
const guidance = extractGuidance(section);
|
||||||
|
if (!guidance) return null;
|
||||||
|
|
||||||
const shadows = [];
|
const shadows = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -631,12 +704,7 @@ function extractElevation(section) {
|
|||||||
for (const inline of extractInlineShadows(b)) dedupe(inline);
|
for (const inline of extractInlineShadows(b)) dedupe(inline);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { ...guidance, shadows };
|
||||||
subtitle: section.subtitle,
|
|
||||||
description,
|
|
||||||
shadows,
|
|
||||||
rules: extractNamedRules(section.lines),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractInlineShadows(text) {
|
function extractInlineShadows(text) {
|
||||||
@@ -768,6 +836,15 @@ function extractDosDonts(section) {
|
|||||||
|
|
||||||
// ---------- Coverage assessment ----------
|
// ---------- Coverage assessment ----------
|
||||||
|
|
||||||
|
// Sections whose model is description-plus-rules only (see extractGuidance).
|
||||||
|
const guidanceCoverage = (guidance) =>
|
||||||
|
guidance
|
||||||
|
? {
|
||||||
|
description: Boolean(guidance.description),
|
||||||
|
rules: guidance.rules.length,
|
||||||
|
}
|
||||||
|
: 'missing';
|
||||||
|
|
||||||
function assessCoverage(model) {
|
function assessCoverage(model) {
|
||||||
const report = {};
|
const report = {};
|
||||||
|
|
||||||
@@ -796,6 +873,8 @@ function assessCoverage(model) {
|
|||||||
}
|
}
|
||||||
: 'missing';
|
: 'missing';
|
||||||
|
|
||||||
|
report.layout = guidanceCoverage(model.layout);
|
||||||
|
|
||||||
report.elevation = model.elevation
|
report.elevation = model.elevation
|
||||||
? {
|
? {
|
||||||
shadows: model.elevation.shadows.length,
|
shadows: model.elevation.shadows.length,
|
||||||
@@ -804,6 +883,8 @@ function assessCoverage(model) {
|
|||||||
}
|
}
|
||||||
: 'missing';
|
: 'missing';
|
||||||
|
|
||||||
|
report.shapes = guidanceCoverage(model.shapes);
|
||||||
|
|
||||||
report.components = model.components
|
report.components = model.components
|
||||||
? {
|
? {
|
||||||
count: model.components.components.length,
|
count: model.components.components.length,
|
||||||
@@ -833,7 +914,9 @@ export function parseDesignMd(md) {
|
|||||||
overview: extractOverview(sections['Overview']),
|
overview: extractOverview(sections['Overview']),
|
||||||
colors: extractColors(sections['Colors']),
|
colors: extractColors(sections['Colors']),
|
||||||
typography: extractTypography(sections['Typography']),
|
typography: extractTypography(sections['Typography']),
|
||||||
|
layout: extractGuidance(sections['Layout']),
|
||||||
elevation: extractElevation(sections['Elevation']),
|
elevation: extractElevation(sections['Elevation']),
|
||||||
|
shapes: extractGuidance(sections['Shapes']),
|
||||||
components: extractComponents(sections['Components']),
|
components: extractComponents(sections['Components']),
|
||||||
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
|
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
export function browserOpenCommand(url, {
|
||||||
|
platform = process.platform,
|
||||||
|
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
|
||||||
|
} = {}) {
|
||||||
|
if (platform === 'darwin') return { command: 'open', args: [url] };
|
||||||
|
if (platform === 'win32') return { command: comspec, args: ['/c', 'start', '', url] };
|
||||||
|
return { command: 'xdg-open', args: [url] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSystemBrowser(url, {
|
||||||
|
platform = process.platform,
|
||||||
|
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
|
||||||
|
spawnImpl = spawn,
|
||||||
|
} = {}) {
|
||||||
|
const { command, args } = browserOpenCommand(url, { platform, comspec });
|
||||||
|
try {
|
||||||
|
const child = spawnImpl(command, args, { stdio: 'ignore', detached: true });
|
||||||
|
child.on('error', () => {});
|
||||||
|
child.unref();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,31 +96,38 @@ function* rank(items, input, idFor = item => item.id) {
|
|||||||
.map(entry => entry.item);
|
.map(entry => entry.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two independent exclusions, and either one is enough to hold a world back.
|
// Rating sets how many tickets a world holds; breadth decides whether it draws
|
||||||
// Rating grades quality: a 3-star earns a second ticket, a 1-star marginal keep
|
// at all. A niche world leaves the pool however good it is, keeping its approval
|
||||||
// leaves the pool. Breadth says whether a world can serve an arbitrary build at
|
// for direct briefs. Breadth was split out of rating because the only way to
|
||||||
// all, so a niche world leaves however good it is, keeping its approval for
|
// hold a narrow world back used to be calling it marginal, which made "excellent
|
||||||
// direct briefs. Breadth was split out of rating because the only way to hold a
|
// but narrow" unrecordable and corrupted ratings as a calibration signal.
|
||||||
// narrow world back used to be calling it marginal, which made "excellent but
|
//
|
||||||
// narrow" unrecordable and corrupted ratings as a calibration signal.
|
// Two tickets for a 3-star, one for everything else, was too sharp. Measured
|
||||||
|
// against the catalog as it stood: 3-star worlds absorbed 57% of the graphic
|
||||||
|
// draw from 65 of 163 eligible worlds, 46% of atmosphere from 13 of 43, and
|
||||||
|
// 75% of interaction from 15 of 25. The reviewer's complaint, that the same
|
||||||
|
// worlds keep coming back, is what a rating multiplier does to a pool whose
|
||||||
|
// thinnest tier holds 25 worlds.
|
||||||
|
//
|
||||||
|
// So a 3-star no longer outdraws a 2-star, and a 1-star draws at half rather
|
||||||
|
// than not at all. A marginal keep is still worth showing sometimes: the
|
||||||
|
// judgement it records is "narrow or unexceptional", not "wrong", and excluding
|
||||||
|
// it entirely made a rating do a job breadth already does properly.
|
||||||
|
const RATING_TICKETS = { 1: 1, 2: 2, 3: 2 };
|
||||||
|
const ticketsForRating = rating => RATING_TICKETS[rating] ?? 2;
|
||||||
|
|
||||||
function challengerTickets(pool) {
|
function challengerTickets(pool) {
|
||||||
return pool.flatMap(concept => {
|
return pool.flatMap(concept => {
|
||||||
const rating = concept.review?.rating;
|
if (concept.review?.breadth === 'niche') return [];
|
||||||
if (rating === 1 || concept.review?.breadth === 'niche') return [];
|
return Array.from({ length: ticketsForRating(concept.review?.rating) },
|
||||||
return rating === 3
|
(_, ticket) => ({ concept, ticket }));
|
||||||
? [{ concept, ticket: 0 }, { concept, ticket: 1 }]
|
|
||||||
: [{ concept, ticket: 0 }];
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function compositionTickets(pool) {
|
function compositionTickets(pool) {
|
||||||
return pool.flatMap(composition => {
|
return pool.flatMap(composition => Array.from(
|
||||||
const rating = composition.review?.rating;
|
{ length: ticketsForRating(composition.review?.rating) },
|
||||||
if (rating === 1) return [];
|
(_, ticket) => ({ composition, ticket })));
|
||||||
return rating === 3
|
|
||||||
? [{ composition, ticket: 0 }, { composition, ticket: 1 }]
|
|
||||||
: [{ composition, ticket: 0 }];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -117,6 +117,23 @@ export function checkDesignDrift({ designPath, projectRoot, threshold = 25 }) {
|
|||||||
* a section can be absent because it never applied, so this is reported as a
|
* a section can be absent because it never applied, so this is reported as a
|
||||||
* documentation gap for a human to judge, never as an error.
|
* documentation gap for a human to judge, never as an error.
|
||||||
*/
|
*/
|
||||||
|
function hasCoverageValue(value) {
|
||||||
|
if (Array.isArray(value)) return value.some(hasCoverageValue);
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.values(value).some(hasCoverageValue);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 && !/^(?:\[\s*\]|\{\s*\})$/.test(trimmed);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEED_DESIGN_MARKERS = ['/', '$'].map((prefix) =>
|
||||||
|
'<!-- SEED: established with the user before implementation; '
|
||||||
|
+ `re-run ${prefix}impeccable document once there's code to capture the actual tokens and components. -->`
|
||||||
|
);
|
||||||
|
|
||||||
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
||||||
if (!design || typeof parseDesignMd !== 'function') return [];
|
if (!design || typeof parseDesignMd !== 'function') return [];
|
||||||
let model;
|
let model;
|
||||||
@@ -125,8 +142,12 @@ export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
|||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const missing = ['colors', 'typography', 'components']
|
const isSeed = SEED_DESIGN_MARKERS.some((marker) => design.includes(marker));
|
||||||
.filter((section) => !model[section]);
|
const requiredSections = isSeed
|
||||||
|
? ['colors', 'typography']
|
||||||
|
: ['colors', 'typography', 'components'];
|
||||||
|
const missing = requiredSections
|
||||||
|
.filter((section) => !model[section] && !hasCoverageValue(model.frontmatter?.[section]));
|
||||||
if (!missing.length) return [];
|
if (!missing.length) return [];
|
||||||
return [finding({
|
return [finding({
|
||||||
id: 'design-md-coverage',
|
id: 'design-md-coverage',
|
||||||
|
|||||||
@@ -3766,7 +3766,10 @@
|
|||||||
const container = copyEditContainerContext(contextElement);
|
const container = copyEditContainerContext(contextElement);
|
||||||
if (container) for (const op of ops) op.container = container;
|
if (container) for (const op of ops) op.container = container;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', {
|
// Token in the query string as well as the body: the URL token is what
|
||||||
|
// authorizes the CORS preflight when the page runs on a non-loopback
|
||||||
|
// dev host (ddev, Valet), since the preflight carries no request body.
|
||||||
|
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -7150,7 +7153,10 @@
|
|||||||
console.debug('[impeccable] Dropped optional live event:', err);
|
console.debug('[impeccable] Dropped optional live event:', err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const doSend = () => fetch('http://localhost:' + PORT + '/events', {
|
// Token in the query string as well as the body: the URL token is what
|
||||||
|
// authorizes the CORS preflight when the page runs on a non-loopback
|
||||||
|
// dev host (ddev, Valet), since the preflight carries no request body.
|
||||||
|
const doSend = () => fetch('http://localhost:' + PORT + '/events?token=' + encodeURIComponent(TOKEN), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(msg),
|
body: JSON.stringify(msg),
|
||||||
@@ -11969,7 +11975,9 @@ void main() {
|
|||||||
rules: [
|
rules: [
|
||||||
...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
|
...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
|
||||||
...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
|
...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
|
||||||
|
...(md.layout?.rules || []).map((r) => ({ ...r, section: 'layout' })),
|
||||||
...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
|
...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
|
||||||
|
...(md.shapes?.rules || []).map((r) => ({ ...r, section: 'shapes' })),
|
||||||
],
|
],
|
||||||
dos: md.dosDonts?.dos || [],
|
dos: md.dosDonts?.dos || [],
|
||||||
donts: md.dosDonts?.donts || [],
|
donts: md.dosDonts?.donts || [],
|
||||||
|
|||||||
@@ -689,16 +689,24 @@ function isLoopbackOrigin(origin) {
|
|||||||
function createRequestHandler({ detectScript, liveScriptParts }) {
|
function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||||
return (req, res) => {
|
return (req, res) => {
|
||||||
const url = new URL(req.url, `http://localhost:${state.port}`);
|
const url = new URL(req.url, `http://localhost:${state.port}`);
|
||||||
// Loopback-restricted CORS. Reflect the caller's Origin only when it is a
|
// Token-or-loopback CORS. Reflect the caller's Origin when it is a
|
||||||
// loopback origin, always paired with `Vary: Origin` so an intermediary
|
// loopback origin OR the request carries the valid session token, always
|
||||||
// cache never serves a response authorized for one origin to another. A
|
// paired with `Vary: Origin` so an intermediary cache never serves a
|
||||||
// remote page (e.g. https://evil.example probing the port from a tab open
|
// response authorized for one origin to another. A remote page (e.g.
|
||||||
// on the same machine) gets no Access-Control-Allow-Origin, so its
|
// https://evil.example probing the port from a tab open on the same
|
||||||
// JS-initiated fetch cannot read any response. Requests with no Origin
|
// machine) has no token and gets no Access-Control-Allow-Origin, so its
|
||||||
// header (script tags, curl, the agent's own fetches) are not subject to
|
// JS-initiated fetch cannot read any response. The token branch exists for
|
||||||
// CORS and keep working; no ACAO header is needed for them.
|
// dev servers on non-localhost loopback aliases (ddev's *.ddev.site,
|
||||||
|
// Valet's *.test, hosts-file entries): the injected classic <script src>
|
||||||
|
// delivers the token to the page regardless of origin, every overlay
|
||||||
|
// request carries it in the query string (preflights included, since
|
||||||
|
// OPTIONS hits the same URL), and a token bearer is already fully
|
||||||
|
// authorized on every route — the token is the security boundary, not the
|
||||||
|
// origin. Requests with no Origin header (script tags, curl, the agent's
|
||||||
|
// own fetches) are not subject to CORS and keep working; no ACAO header
|
||||||
|
// is needed for them.
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (origin && isLoopbackOrigin(origin)) {
|
if (origin && (isLoopbackOrigin(origin) || url.searchParams.get('token') === state.token)) {
|
||||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
res.setHeader('Vary', 'Origin');
|
res.setHeader('Vary', 'Origin');
|
||||||
}
|
}
|
||||||
@@ -865,7 +873,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
|||||||
// { present, parsed, sidecar, hasMd, hasSidecar,
|
// { present, parsed, sidecar, hasMd, hasSidecar,
|
||||||
// mdNewerThanJson, parseError?, sidecarError? }
|
// mdNewerThanJson, parseError?, sidecarError? }
|
||||||
// - parsed: output of parseDesignMd (frontmatter
|
// - parsed: output of parseDesignMd (frontmatter
|
||||||
// + six canonical sections) when DESIGN.md exists.
|
// + the canonical sections) when DESIGN.md exists.
|
||||||
// - sidecar: .impeccable/design.json contents when present.
|
// - sidecar: .impeccable/design.json contents when present.
|
||||||
// Expected shape: schemaVersion 2, carrying
|
// Expected shape: schemaVersion 2, carrying
|
||||||
// extensions + components + narrative.
|
// extensions + components + narrative.
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* Framework-neutral Impeccable live chrome contract.
|
|
||||||
*
|
|
||||||
* The production browser bundle is intentionally plain DOM so Svelte, React,
|
|
||||||
* Vue, and static adapters can all mount the same chrome. This module is the
|
|
||||||
* testable contract/inventory for that bundle; live-browser.js mirrors these
|
|
||||||
* values at runtime because it is served as a standalone script.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
|
|
||||||
'root',
|
|
||||||
'transport',
|
|
||||||
'state',
|
|
||||||
'actions',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const LIVE_UI_SURFACES = Object.freeze([
|
|
||||||
{
|
|
||||||
key: 'global-bottom-bar',
|
|
||||||
ids: [
|
|
||||||
'impeccable-live-global-bar',
|
|
||||||
'impeccable-live-global-bar-brand',
|
|
||||||
'impeccable-live-pick-toggle',
|
|
||||||
'impeccable-live-insert-toggle',
|
|
||||||
'impeccable-live-detect-toggle',
|
|
||||||
'impeccable-live-detect-badge',
|
|
||||||
'impeccable-live-design-toggle',
|
|
||||||
'impeccable-live-page-chat',
|
|
||||||
'impeccable-live-page-chat-input',
|
|
||||||
'impeccable-live-page-chat-voice',
|
|
||||||
],
|
|
||||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'pending-copy-edit-dock',
|
|
||||||
ids: ['impeccable-live-pending-dock'],
|
|
||||||
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'element-selection-chrome',
|
|
||||||
ids: [
|
|
||||||
'impeccable-live-highlight',
|
|
||||||
'impeccable-live-tooltip',
|
|
||||||
'impeccable-live-bar',
|
|
||||||
'impeccable-live-selection-pill',
|
|
||||||
'impeccable-live-input',
|
|
||||||
'impeccable-live-configure-voice',
|
|
||||||
'impeccable-live-configure-bar-tooltip',
|
|
||||||
],
|
|
||||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'action-picker',
|
|
||||||
ids: ['impeccable-live-picker'],
|
|
||||||
states: ['closed', 'open', 'option-hover', 'option-focus'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'edit-chrome',
|
|
||||||
ids: ['impeccable-live-edit-badge'],
|
|
||||||
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'generating-row',
|
|
||||||
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
|
|
||||||
states: ['action-label', 'animated-dots', 'generating', 'done'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'variant-cycling-row',
|
|
||||||
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
|
|
||||||
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'variant-params-panel',
|
|
||||||
ids: ['impeccable-live-params-panel'],
|
|
||||||
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'saving-confirmed-rows',
|
|
||||||
ids: ['impeccable-live-bar'],
|
|
||||||
states: ['saving', 'applying-variant', 'confirmed'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'insert-mode-chrome',
|
|
||||||
ids: [
|
|
||||||
'impeccable-live-insert-line',
|
|
||||||
'impeccable-live-insert-placeholder',
|
|
||||||
'impeccable-live-placeholder-resize',
|
|
||||||
'impeccable-live-insert-input',
|
|
||||||
'impeccable-live-insert-voice',
|
|
||||||
'impeccable-live-insert-create',
|
|
||||||
'impeccable-live-insert-create-tooltip',
|
|
||||||
],
|
|
||||||
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'annotation-chrome',
|
|
||||||
ids: [
|
|
||||||
'impeccable-live-annot',
|
|
||||||
'impeccable-live-annot-svg',
|
|
||||||
'impeccable-live-annot-pins',
|
|
||||||
'impeccable-live-annot-clear',
|
|
||||||
],
|
|
||||||
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'design-system-panel',
|
|
||||||
ids: ['impeccable-live-design-host'],
|
|
||||||
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'toasts-and-errors',
|
|
||||||
ids: ['impeccable-live-toast'],
|
|
||||||
states: ['normal', 'error', 'no-variants-mounted'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'css-isolation-boundary',
|
|
||||||
ids: ['impeccable-live-root'],
|
|
||||||
states: ['shadow-root', 'style-tags', 'hostile-css'],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
|
|
||||||
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function resolveLiveUiRoot(env = globalThis) {
|
|
||||||
const doc = env?.document;
|
|
||||||
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|
|
||||||
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
|
|
||||||
if (explicit && typeof explicit.appendChild === 'function') return explicit;
|
|
||||||
return doc?.body || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLiveUiElementById(id, env = globalThis) {
|
|
||||||
const doc = env?.document;
|
|
||||||
const root = resolveLiveUiRoot(env);
|
|
||||||
if (!id) return null;
|
|
||||||
if (root?.getElementById) {
|
|
||||||
const found = root.getElementById(id);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
if (root?.querySelector) {
|
|
||||||
const found = root.querySelector('#' + escapeCssIdent(id));
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return doc?.getElementById?.(id) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendToLiveUiRoot(el, env = globalThis) {
|
|
||||||
const root = resolveLiveUiRoot(env);
|
|
||||||
if (!root) throw new Error('Impeccable live UI root is not available');
|
|
||||||
root.appendChild(el);
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
|
|
||||||
const doc = env?.document;
|
|
||||||
const root = resolveLiveUiRoot(env);
|
|
||||||
if (root && root !== doc?.body) {
|
|
||||||
root.appendChild(styleEl);
|
|
||||||
} else {
|
|
||||||
(doc?.head || doc?.body || root).appendChild(styleEl);
|
|
||||||
}
|
|
||||||
return styleEl;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activeElementDeep(doc = globalThis.document) {
|
|
||||||
let active = doc?.activeElement || null;
|
|
||||||
while (active?.shadowRoot?.activeElement) {
|
|
||||||
active = active.shadowRoot.activeElement;
|
|
||||||
}
|
|
||||||
return active;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeCssIdent(value) {
|
|
||||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
||||||
return CSS.escape(String(value));
|
|
||||||
}
|
|
||||||
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,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', '.github', '.grok',
|
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
|
||||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -93,15 +93,17 @@ function commandPrefixForSkillsDir(skillsDir) {
|
|||||||
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
|
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
function generatePinnedSkill(command, metadata, commandPrefix) {
|
function generatePinnedSkill(command, metadata, commandPrefix, isCodex) {
|
||||||
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
|
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
|
||||||
const hint = metadata[command]?.argumentHint || '[target]';
|
const hint = metadata[command]?.argumentHint || '[target]';
|
||||||
|
const providerFrontmatter = isCodex
|
||||||
|
? `metadata:\n argument-hint: "${hint}"`
|
||||||
|
: `argument-hint: "${hint}"\nuser-invocable: true`;
|
||||||
|
|
||||||
return `---
|
return `---
|
||||||
name: ${command}
|
name: ${command}
|
||||||
description: "${desc}"
|
description: "${desc}"
|
||||||
argument-hint: "${hint}"
|
${providerFrontmatter}
|
||||||
user-invocable: true
|
|
||||||
---
|
---
|
||||||
|
|
||||||
${PIN_MARKER}
|
${PIN_MARKER}
|
||||||
@@ -128,7 +130,7 @@ function pin(command, projectRoot) {
|
|||||||
|
|
||||||
for (const skillsDir of harnessDirs) {
|
for (const skillsDir of harnessDirs) {
|
||||||
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
|
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
|
||||||
const content = generatePinnedSkill(command, metadata, commandPrefix);
|
const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$');
|
||||||
// Check if skill already exists (and isn't a pin)
|
// Check if skill already exists (and isn't a pin)
|
||||||
const skillDir = join(skillsDir, command);
|
const skillDir = join(skillsDir, command);
|
||||||
if (existsSync(skillDir)) {
|
if (existsSync(skillDir)) {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { openSystemBrowser } from './lib/open-system-browser.mjs';
|
||||||
|
|
||||||
function arg(name, fallback = null) {
|
function arg(name, fallback = null) {
|
||||||
const i = process.argv.indexOf(`--${name}`);
|
const i = process.argv.indexOf(`--${name}`);
|
||||||
@@ -143,7 +144,7 @@ if (hasFlag('schema')) {
|
|||||||
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/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/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.', 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: '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: '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' },
|
||||||
],
|
],
|
||||||
reroll: true,
|
reroll: true,
|
||||||
@@ -151,7 +152,7 @@ if (hasFlag('schema')) {
|
|||||||
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.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/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. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
|
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. 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.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +322,11 @@ 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 hasBack = (option) => Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
|
const hasMedia = (option) => Boolean(option.sketchSrc || option.heroSrc || option.boardSrc);
|
||||||
|
// The back exists to keep long facts off a card whose front is an image;
|
||||||
|
// a card with no art has no flip chip to reach it, so it gets no back and
|
||||||
|
// the full read lives on the front instead.
|
||||||
|
const hasBack = (option) => hasMedia(option) && Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
|
||||||
const anatomy = (option) => {
|
const anatomy = (option) => {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
if (option.thesis) rows.push(`<p class="thesis">${esc(option.thesis)}</p>`);
|
if (option.thesis) rows.push(`<p class="thesis">${esc(option.thesis)}</p>`);
|
||||||
@@ -336,7 +341,15 @@ function page() {
|
|||||||
// 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 sketch lands, the first viewport is a picture.
|
||||||
rows.push(fact('Risk', option.risk, 'clamp'));
|
// With no art there is no back, so the full read fills the room the
|
||||||
|
// image would have taken.
|
||||||
|
if (hasMedia(option)) {
|
||||||
|
rows.push(fact('Risk', option.risk, 'clamp'));
|
||||||
|
} else {
|
||||||
|
rows.push(fact('First viewport', option.viewport));
|
||||||
|
rows.push(fact('The case', option.case));
|
||||||
|
rows.push(fact('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>`);
|
||||||
else if (option.body && option.thesis && !hasBack(option)) rows.push(`<p class="detail more">${esc(option.body)}</p>`);
|
else if (option.body && option.thesis && !hasBack(option)) rows.push(`<p class="detail more">${esc(option.body)}</p>`);
|
||||||
return rows.join('\n ');
|
return rows.join('\n ');
|
||||||
@@ -362,8 +375,11 @@ function page() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
if (option.heroSrc || option.boardSrc) {
|
if (option.heroSrc || option.boardSrc) {
|
||||||
return `<div class="media">
|
// Without a sketch the catalog art is the card's face; it stays a
|
||||||
|
// labeled reference so it never reads as the promise of the build.
|
||||||
|
return `<div class="media" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||||
|
<p class="media-label">inspiration</p>
|
||||||
<div class="chips">${expandChip}${details}</div>
|
<div class="chips">${expandChip}${details}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -547,6 +563,17 @@ function page() {
|
|||||||
.pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 3px 0 4px; background: oklch(7% 0.006 95 / 0.72); backdrop-filter: blur(3px); }
|
.pip 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); }
|
.sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||||
|
/* Catalog art standing in for a sketchless card is a reference, and says so
|
||||||
|
on its face; the same pill later carries "artwork unavailable". */
|
||||||
|
.media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); padding: 3px 8px 4px; background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 4px; backdrop-filter: blur(3px); }
|
||||||
|
/* Art that never arrives collapses to the card's own palette (painted
|
||||||
|
inline from its swatches) instead of sitting as a dark void wearing a
|
||||||
|
zoom cursor; the scrim keeps the label legible over saturated fields,
|
||||||
|
passes clicks through, and the flip chips stay above it. A card with no
|
||||||
|
palette falls back to the quiet graphite field. */
|
||||||
|
.media.unavailable { background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
|
||||||
|
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
|
||||||
|
.media.unavailable .chips { z-index: 2; }
|
||||||
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
||||||
the real sketch whenever it lands. */
|
the real sketch whenever it lands. */
|
||||||
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
||||||
@@ -709,6 +736,36 @@ function page() {
|
|||||||
tryLoad();
|
tryLoad();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A declared image that never loads (missing catalog asset, offline shell)
|
||||||
|
// must not sit as a dark void: the slot collapses to the card's own
|
||||||
|
// palette, labeled honestly, and the card competes on its facts. Sketch
|
||||||
|
// slots are excluded; their polling owns the wait.
|
||||||
|
const artFailed = (img) => {
|
||||||
|
const m = img.closest('.media');
|
||||||
|
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
|
||||||
|
m.classList.add('unavailable');
|
||||||
|
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
|
||||||
|
if (colors.length) m.style.background = 'linear-gradient(135deg, ' + colors.map((c, i) => c + ' ' + Math.round(i * 100 / colors.length) + '% ' + Math.round((i + 1) * 100 / colors.length) + '%').join(', ') + ')';
|
||||||
|
m.querySelector('.media-label')?.remove();
|
||||||
|
m.querySelector('.chip.expand')?.remove();
|
||||||
|
m.removeAttribute('title');
|
||||||
|
img.remove();
|
||||||
|
const label = document.createElement('p');
|
||||||
|
label.className = 'media-label';
|
||||||
|
label.textContent = 'artwork unavailable';
|
||||||
|
m.appendChild(label);
|
||||||
|
};
|
||||||
|
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
|
||||||
|
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
||||||
|
else img.addEventListener('error', () => artFailed(img), { once: true });
|
||||||
|
});
|
||||||
|
// A broken inspiration PIP just leaves; nothing depends on it.
|
||||||
|
document.querySelectorAll('.pip img').forEach(img => {
|
||||||
|
const gone = () => img.closest('.pip')?.remove();
|
||||||
|
if (img.complete && img.naturalWidth === 0) gone();
|
||||||
|
else img.addEventListener('error', gone, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Inspiration PIP opens the full catalog card in the lightbox.
|
// Inspiration PIP opens the full catalog card in the lightbox.
|
||||||
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
|
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -920,8 +977,7 @@ server.listen(portArg, '127.0.0.1', () => {
|
|||||||
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
|
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
|
||||||
}
|
}
|
||||||
if (!hasFlag('no-open')) {
|
if (!hasFlag('no-open')) {
|
||||||
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
openSystemBrowser(url);
|
||||||
try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ }
|
|
||||||
}
|
}
|
||||||
if (timeoutSec > 0) {
|
if (timeoutSec > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
|
|||||||
- **Type:** body measure 65–75ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
|
- **Type:** body measure 65–75ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
|
||||||
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
|
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
|
||||||
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
|
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
|
||||||
|
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
|
||||||
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
|
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
|
||||||
- **Coverage:** every brief requirement present and findable within seconds.
|
- **Coverage:** every brief requirement present and findable within seconds.
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
|
|||||||
## 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
|
||||||
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
|
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.
|
||||||
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.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ The script assigns which structure gets built; your top-ranked structure is what
|
|||||||
|
|
||||||
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 .gemini/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, 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 .gemini/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||||
|
|
||||||
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, the canon card included. Serve the page first, then produce the sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
|
When image generation exists, every card also declares a `sketch` path under `.impeccable/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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -84,9 +84,12 @@ For `shape`, return the selected direction to [shape.md](shape.md) and stop befo
|
|||||||
|
|
||||||
## 6. Build with full commitment
|
## 6. Build with full commitment
|
||||||
|
|
||||||
|
When an approved comp exists, the comp is king, and the build happens in phases. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses.
|
||||||
|
|
||||||
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
|
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
|
||||||
|
|
||||||
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
|
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
|
||||||
|
- **Prove the hero before building past it.** When an approved comp exists, render the first viewport, capture it, and set it beside the comp's first viewport before any later section: the hero carries the run's ambition, and every following section inherits its shortfall. Judge scale and density as quantities, a field at a tenth of the comp's coverage or type at half its weight is a different design, and a five-minute retry here is what a rebuild verdict at the finish costs when this check is skipped.
|
||||||
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
|
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
|
||||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
|
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
|
||||||
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
|
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
|
||||||
@@ -100,6 +103,6 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
|||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
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 .gemini/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch: put that verdict in front of the user with the named comp regions and let them choose between a re-derivation and shipping as it stands. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
|
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. Where this harness runs no design hook, run `node .gemini/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.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ After approval, record the choice where tools can find it: the approved comp's p
|
|||||||
|
|
||||||
## Inventory implementation fidelity
|
## Inventory implementation fidelity
|
||||||
|
|
||||||
Before building, inventory the approved comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same written inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
|
Before building, read the approved comp as a design system and record it in the brief: component grammar, corner language, line weights, elevation treatment, and the type ramp, because everything the comp does not show gets built from this record, and without it the fallback is the model's stock kit of square boxes, 1px grids, bento cells, and hard shadows. Then inventory the comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same written inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. The primary action gets its own row with its own medium: when the comp dissolves, stamps, erodes, or otherwise physically works the main CTA, that treatment is signature material on the page's most important element, and shrinking it to a border trick or a few decorative pixels is the compliance-token version of commitment. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
|
||||||
|
|
||||||
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack; writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish, is not a medium choice, it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly, diagrams with countable elements, controls, flat shape systems, and it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region instead of producing it is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity, so "no photography on hand" forbids fake proof, not an illustrated hero.
|
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack, and so is any texture by that name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient or layered background is not a texture medium and "layered CSS textures" is not a medium at all. Writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish or a cotton field's weave, is not a medium choice, it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly, diagrams with countable elements, controls, flat shape systems, and it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region instead of producing it is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity, so "no photography on hand" forbids fake proof, not an illustrated hero.
|
||||||
|
|
||||||
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond, and code executed safely and professionally remains first-class there. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
|
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond, and code executed safely and professionally remains first-class there. A field or texture built from many small elements carries a quantity commitment either way: write down its approximate density and coverage ("thousands of glyphs over two-thirds of the fold, dense at the top fading into the path"), because a field rebuilt at a tenth of its density passes every checklist and still is not the design. TYPE rows carry the same discipline: name the face's compression class, and render one headline word against the comp before building on it; a visibly wider or lighter silhouette means the face is wrong, and every section built on it inherits the miss. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
|
||||||
|
|
||||||
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies.
|
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies.
|
||||||
|
|
||||||
@@ -42,6 +42,8 @@ Treat the comp as a north star, not something to trace, and know what that allow
|
|||||||
|
|
||||||
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .gemini/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
|
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .gemini/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
|
||||||
|
|
||||||
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
|
When the harness runs subagents, spawn the shipped asset producer every time, even when the inventory's produce bucket looks empty: its manifest is the independent second opinion on your media, and runs that skipped the spawn are the runs whose cotton became CSS. An honestly empty manifest costs one cheap spawn; a wrongly empty produce bucket costs the build its materials. Use the producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
|
||||||
|
|
||||||
|
Convert images with a converter context.mjs reported at boot (the IMAGE_TOOLS line); probe only when it reported none, at most once per session, never per image.
|
||||||
|
|
||||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||||
|
|||||||
@@ -405,13 +405,18 @@ ASSIGNED INDEX: ${buildIndex}
|
|||||||
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.
|
||||||
No challengers this run: the roll service was unreachable and no local
|
No challengers this run: the roll service was unreachable and no local
|
||||||
catalog exists. A sandboxed exec tool with no network access causes exactly
|
catalog exists. A sandboxed shell without network egress is the most common
|
||||||
this; before accepting degradation, rerun this command once through the
|
cause; before accepting degradation, rerun this command once through the
|
||||||
harness's network-enabled command tool. A sandboxed shell without network egress is the most common
|
harness's network-enabled or escalated command tool. When that rerun needs
|
||||||
cause: if this harness can rerun the command with network access granted,
|
an approval, state exactly what the approver must know: this script's only
|
||||||
do that once before proceeding. Otherwise proceed with the grounded
|
network contact is one GET to https://impeccable.style/api/roll whose query
|
||||||
candidates alone; the assignment
|
carries scope, mode, an eight-hex seed key, and a re-roll counter; no
|
||||||
above still applies at full strength. Tell the user plainly that this roll
|
project files, prompts, code, or conversation context are transmitted, and
|
||||||
|
nothing is written. An approval request naming that URL and payload judges
|
||||||
|
the real action; a bare "run with network" invites rejection for contacting
|
||||||
|
an unspecified domain. If the rerun is still refused, proceed with the
|
||||||
|
grounded candidates alone; the assignment above still applies at full
|
||||||
|
strength. Tell the user plainly that this roll
|
||||||
ran degraded, with no challengers and no quality-bar boards; do not present
|
ran degraded, with no challengers and no quality-bar boards; do not present
|
||||||
the outcome as a full roll. A degraded roll changes the cards, not the
|
the outcome as a full roll. A degraded roll changes the cards, not the
|
||||||
channel: when a browser can open, present the direction on the decision page
|
channel: when a browser can open, present the direction on the decision page
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { loadContext, extractPlatform } from './context.mjs';
|
import { loadContext, extractPlatform } from './context.mjs';
|
||||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
import { readLatestSnapshotAcrossTargets } from './critique-storage.mjs';
|
||||||
|
|
||||||
/** Is there code here at all, or just context files / an empty repo? */
|
/** Is there code here at all, or just context files / an empty repo? */
|
||||||
function hasCode(cwd) {
|
function hasCode(cwd) {
|
||||||
@@ -34,23 +34,13 @@ function hasCode(cwd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The most recent critique snapshot across all targets. Filenames are
|
* Summarize the most recent critique snapshot across all targets.
|
||||||
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
|
|
||||||
* Parses the small frontmatter for score + P0/P1 counts.
|
|
||||||
*/
|
*/
|
||||||
function latestCritique(cwd) {
|
function latestCritique(cwd) {
|
||||||
try {
|
try {
|
||||||
const dir = getCritiqueDir(cwd);
|
const latest = readLatestSnapshotAcrossTargets({ cwd });
|
||||||
if (!fs.existsSync(dir)) return null;
|
if (!latest) return null;
|
||||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
|
const get = (key) => latest.meta[key] ?? null;
|
||||||
if (!files.length) return null;
|
|
||||||
const newest = files[files.length - 1];
|
|
||||||
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
|
|
||||||
const front = text.split('---')[1] || '';
|
|
||||||
const get = (k) => {
|
|
||||||
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
|
|
||||||
return m ? m[1].trim() : null;
|
|
||||||
};
|
|
||||||
const num = (v) => {
|
const num = (v) => {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
@@ -61,7 +51,7 @@ function latestCritique(cwd) {
|
|||||||
p0: num(get('p0')),
|
p0: num(get('p0')),
|
||||||
p1: num(get('p1')),
|
p1: num(get('p1')),
|
||||||
timestamp: get('timestamp'),
|
timestamp: get('timestamp'),
|
||||||
file: path.relative(cwd, path.join(dir, newest)),
|
file: path.relative(cwd, latest.path),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
* shape rather than the markdown block.
|
* shape rather than the markdown block.
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -1146,6 +1147,7 @@ async function cli() {
|
|||||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||||
parts.push(buildMissingTargetDirective());
|
parts.push(buildMissingTargetDirective());
|
||||||
}
|
}
|
||||||
|
appendImageToolsDirective(parts);
|
||||||
appendStalenessDirective(parts, ctx, cliOptions);
|
appendStalenessDirective(parts, ctx, cliOptions);
|
||||||
if (updateDirective) parts.push(updateDirective);
|
if (updateDirective) parts.push(updateDirective);
|
||||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||||
@@ -1180,6 +1182,7 @@ async function cli() {
|
|||||||
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
|
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
appendImageToolsDirective(parts);
|
||||||
appendStalenessDirective(parts, ctx, cliOptions);
|
appendStalenessDirective(parts, ctx, cliOptions);
|
||||||
if (!ctx.platform) {
|
if (!ctx.platform) {
|
||||||
// A `## Platform` section that names something we don't recognize (a
|
// A `## Platform` section that names something we don't recognize (a
|
||||||
@@ -1275,9 +1278,10 @@ function appendImageGenDirective(parts) {
|
|||||||
if (!process.env.OPENAI_API_KEY) return;
|
if (!process.env.OPENAI_API_KEY) return;
|
||||||
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
|
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
|
||||||
parts.push([
|
parts.push([
|
||||||
'IMAGE_GEN_AVAILABLE: An OpenAI key is present, so image generation works even without a harness-native image tool:',
|
'IMAGE_GEN_AVAILABLE: your harness-native image tool is always the first choice for generation; use it whenever one exists.',
|
||||||
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render).`,
|
'This environment also carries an OpenAI key as the fallback for harnesses with no native tool:',
|
||||||
'Prefer the harness-native image tool when one exists. Visualizing a direction before building it measurably strengthens the result.',
|
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render, and never reach for it when a native tool exists).`,
|
||||||
|
'Visualizing a direction before building it measurably strengthens the result.',
|
||||||
].join(' '));
|
].join(' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1332,6 +1336,19 @@ function appendDetectorFallback(parts, ctx) {
|
|||||||
// markdown already in memory, a bounded set of stats, or one of the small JSON
|
// markdown already in memory, a bounded set of stats, or one of the small JSON
|
||||||
// files the boot reads regardless. The deep pass (git drift, token divergence,
|
// files the boot reads regardless. The deep pass (git drift, token divergence,
|
||||||
// cross-workspace sweep) belongs to the doctor command, not to every session.
|
// cross-workspace sweep) belongs to the doctor command, not to every session.
|
||||||
|
// One boot-time probe replaces every session re-deriving its image toolchain:
|
||||||
|
// harnesses and OSes differ (cwebp, sips on macOS, magick, ffmpeg), and the
|
||||||
|
// agent should read this line instead of running command -v per image.
|
||||||
|
function appendImageToolsDirective(parts) {
|
||||||
|
const probe = process.platform === 'win32' ? 'where' : 'which';
|
||||||
|
const found = ['cwebp', 'sips', 'magick', 'ffmpeg'].filter((tool) => {
|
||||||
|
try { return spawnSync(probe, [tool], { stdio: 'ignore' }).status === 0; } catch { return false; }
|
||||||
|
});
|
||||||
|
parts.push(found.length
|
||||||
|
? `IMAGE_TOOLS: available image converters on this machine: ${found.join(', ')}. Use the first suitable one; never probe again this session.`
|
||||||
|
: 'IMAGE_TOOLS: no image converter found (cwebp, sips, magick, ffmpeg). Ship PNG output unconverted rather than probing per image.');
|
||||||
|
}
|
||||||
|
|
||||||
function appendStalenessDirective(parts, ctx, options) {
|
function appendStalenessDirective(parts, ctx, options) {
|
||||||
const projectRoot = ctx.projectRoot || process.cwd();
|
const projectRoot = ctx.projectRoot || process.cwd();
|
||||||
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
|
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
|
||||||
|
|||||||
@@ -105,28 +105,37 @@ function parseFrontmatter(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return all snapshot files for `slug`, sorted oldest → newest.
|
* Return snapshot files matching `suffix`, sorted oldest → newest.
|
||||||
*/
|
*/
|
||||||
function listSnapshotsForSlug(slug, cwd) {
|
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
|
||||||
|
|
||||||
|
function listSnapshots(suffix, cwd) {
|
||||||
const dir = getCritiqueDir(cwd);
|
const dir = getCritiqueDir(cwd);
|
||||||
if (!fs.existsSync(dir)) return [];
|
if (!fs.existsSync(dir)) return [];
|
||||||
const suffix = `__${slug}.md`;
|
|
||||||
return fs.readdirSync(dir)
|
return fs.readdirSync(dir)
|
||||||
.filter((f) => f.endsWith(suffix))
|
.filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
|
||||||
.sort()
|
.sort()
|
||||||
.map((f) => path.join(dir, f));
|
.map((f) => path.join(dir, f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readLatestSnapshotMatching(suffix, cwd) {
|
||||||
|
const filePath = listSnapshots(suffix, cwd).at(-1);
|
||||||
|
if (!filePath) return null;
|
||||||
|
const body = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return { path: filePath, body, meta: parseFrontmatter(body) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
||||||
* to find its fix backlog when the slug matches.
|
* to find its fix backlog when the slug matches.
|
||||||
*/
|
*/
|
||||||
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||||
const all = listSnapshotsForSlug(slug, cwd);
|
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
|
||||||
if (!all.length) return null;
|
}
|
||||||
const latest = all[all.length - 1];
|
|
||||||
const body = fs.readFileSync(latest, 'utf-8');
|
/** Return the most recent snapshot across all targets, or null. */
|
||||||
return { path: latest, body, meta: parseFrontmatter(body) };
|
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
|
||||||
|
return readLatestSnapshotMatching('.md', cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,7 +143,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
|||||||
* Critique appends a one-line trend to its output using this.
|
* Critique appends a one-line trend to its output using this.
|
||||||
*/
|
*/
|
||||||
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
||||||
const all = listSnapshotsForSlug(slug, cwd);
|
const all = listSnapshots(`__${slug}.md`, cwd);
|
||||||
const slice = all.slice(-limit);
|
const slice = all.slice(-limit);
|
||||||
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
|
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,10 +142,73 @@ function stripInlineYamlComment(s) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YAML double-quoted scalars process backslash escapes. Stripping the outer
|
||||||
|
// quotes without unescaping leaves them in place, so a nested font family like
|
||||||
|
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
|
||||||
|
// reaches allowedFonts as '\"ibm plex sans' and never matches the same family
|
||||||
|
// declared in CSS. Scanner instead of a regex: the escape set is small and the
|
||||||
|
// backslash handling stays readable.
|
||||||
|
// The full YAML 1.2 double-quote escape set (spec section 5.7).
|
||||||
|
const YAML_SIMPLE_ESCAPES = {
|
||||||
|
'0': '\0',
|
||||||
|
a: '\x07',
|
||||||
|
b: '\b',
|
||||||
|
t: '\t',
|
||||||
|
n: '\n',
|
||||||
|
v: '\v',
|
||||||
|
f: '\f',
|
||||||
|
r: '\r',
|
||||||
|
e: '\x1b',
|
||||||
|
' ': ' ',
|
||||||
|
'"': '"',
|
||||||
|
'/': '/',
|
||||||
|
'\\': '\\',
|
||||||
|
N: '\u0085',
|
||||||
|
_: '\u00a0',
|
||||||
|
L: '\u2028',
|
||||||
|
P: '\u2029',
|
||||||
|
};
|
||||||
|
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
|
||||||
|
|
||||||
|
function unescapeYamlDoubleQuoted(body) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
const ch = body[i];
|
||||||
|
if (ch !== '\\' || i === body.length - 1) {
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = body[i + 1];
|
||||||
|
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
|
||||||
|
out += YAML_SIMPLE_ESCAPES[next];
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
|
||||||
|
// literal rather than corrupting the rest of the scalar.
|
||||||
|
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
|
||||||
|
if (hexLen) {
|
||||||
|
const hex = body.slice(i + 2, i + 2 + hexLen);
|
||||||
|
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
|
||||||
|
if (codePoint >= 0 && codePoint <= 0x10ffff) {
|
||||||
|
out += String.fromCodePoint(codePoint);
|
||||||
|
i += 1 + hexLen;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function parseScalar(raw) {
|
function parseScalar(raw) {
|
||||||
const s = raw.trim();
|
const s = raw.trim();
|
||||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||||
return s.slice(1, -1);
|
return unescapeYamlDoubleQuoted(s.slice(1, -1));
|
||||||
|
}
|
||||||
|
// Single-quoted YAML escapes only the quote itself, by doubling it.
|
||||||
|
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
|
||||||
|
return s.slice(1, -1).split("''").join("'");
|
||||||
}
|
}
|
||||||
if (s === 'true') return true;
|
if (s === 'true') return true;
|
||||||
if (s === 'false') return false;
|
if (s === 'false') return false;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.m
|
|||||||
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
||||||
export {
|
export {
|
||||||
walkDir,
|
walkDir,
|
||||||
|
hasScannableExtension,
|
||||||
SCANNABLE_EXTENSIONS,
|
SCANNABLE_EXTENSIONS,
|
||||||
SKIP_DIRS,
|
SKIP_DIRS,
|
||||||
buildImportGraph,
|
buildImportGraph,
|
||||||
|
|||||||
@@ -41,6 +41,221 @@ function shouldRunPageAnalyzers(content, filePath) {
|
|||||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
||||||
|
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
|
||||||
|
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
|
||||||
|
|
||||||
|
function isInsideOpeningJsxTag(source) {
|
||||||
|
const tagStart = source.lastIndexOf('<');
|
||||||
|
if (tagStart === -1 || !/^<[A-Za-z][\w.:-]*/.test(source.slice(tagStart))) return false;
|
||||||
|
|
||||||
|
let quote = '';
|
||||||
|
for (let cursor = tagStart + 1; cursor < source.length; cursor++) {
|
||||||
|
const char = source[cursor];
|
||||||
|
if (quote) {
|
||||||
|
if (char === '\\') cursor++;
|
||||||
|
else if (char === quote) quote = '';
|
||||||
|
} else if (char === "'" || char === '"') {
|
||||||
|
quote = char;
|
||||||
|
} else if (char === '>') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blank JavaScript comments without moving any following source. Regex
|
||||||
|
* findings keep their original line numbers, while prose examples inside
|
||||||
|
* comments cannot masquerade as rendered markup.
|
||||||
|
*/
|
||||||
|
function stripJsComments(content, options = {}) {
|
||||||
|
let state = 'code';
|
||||||
|
let output = '';
|
||||||
|
let lastSignificant = '';
|
||||||
|
let previousSignificant = '';
|
||||||
|
let antePreviousSignificant = '';
|
||||||
|
let currentWord = '';
|
||||||
|
let currentWordPrefix = '';
|
||||||
|
let wordSeparated = false;
|
||||||
|
let regexCharClass = false;
|
||||||
|
let jsxExpressionDepth = 0;
|
||||||
|
let lastClosedBraceKind = '';
|
||||||
|
const braceKinds = [];
|
||||||
|
const templateExpressionDepths = [];
|
||||||
|
|
||||||
|
const braceKind = (startsJsxExpression = false) => (
|
||||||
|
!startsJsxExpression && (
|
||||||
|
!lastSignificant ||
|
||||||
|
lastSignificant === ')' ||
|
||||||
|
lastSignificant === ';' ||
|
||||||
|
lastSignificant === '}' ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
||||||
|
) ? 'block' : 'expression'
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordSignificant = (char) => {
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
wordSeparated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isWordChar = /[\w$]/.test(char);
|
||||||
|
if (isWordChar && (wordSeparated || !currentWord)) {
|
||||||
|
currentWord = '';
|
||||||
|
currentWordPrefix = lastSignificant;
|
||||||
|
} else if (!isWordChar) {
|
||||||
|
currentWordPrefix = '';
|
||||||
|
}
|
||||||
|
wordSeparated = false;
|
||||||
|
antePreviousSignificant = previousSignificant;
|
||||||
|
previousSignificant = lastSignificant;
|
||||||
|
lastSignificant = char;
|
||||||
|
currentWord = isWordChar ? currentWord + char : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
const char = content[i];
|
||||||
|
const next = content[i + 1];
|
||||||
|
|
||||||
|
if (state === 'line-comment') {
|
||||||
|
if (char === '\n') {
|
||||||
|
output += char;
|
||||||
|
state = 'code';
|
||||||
|
} else {
|
||||||
|
output += ' ';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'block-comment') {
|
||||||
|
if (char === '*' && next === '/') {
|
||||||
|
output += ' ';
|
||||||
|
i++;
|
||||||
|
state = 'code';
|
||||||
|
} else {
|
||||||
|
output += char === '\n' ? '\n' : ' ';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'regex') {
|
||||||
|
output += char;
|
||||||
|
if (char === '\\' && next) {
|
||||||
|
output += next;
|
||||||
|
i++;
|
||||||
|
} else if (char === '[') {
|
||||||
|
regexCharClass = true;
|
||||||
|
} else if (char === ']') {
|
||||||
|
regexCharClass = false;
|
||||||
|
} else if (char === '/' && !regexCharClass) {
|
||||||
|
state = 'code';
|
||||||
|
recordSignificant('/');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'template' && char === '$' && next === '{') {
|
||||||
|
output += '${';
|
||||||
|
i++;
|
||||||
|
recordSignificant('$');
|
||||||
|
recordSignificant('{');
|
||||||
|
templateExpressionDepths.push(1);
|
||||||
|
braceKinds.push('expression');
|
||||||
|
if (jsxExpressionDepth) jsxExpressionDepth++;
|
||||||
|
state = 'code';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== 'code') {
|
||||||
|
output += char;
|
||||||
|
if (char === '\\' && next) {
|
||||||
|
output += next;
|
||||||
|
i++;
|
||||||
|
} else if (
|
||||||
|
(state === 'single-quote' && char === "'") ||
|
||||||
|
(state === 'double-quote' && char === '"') ||
|
||||||
|
(state === 'template' && char === '`')
|
||||||
|
) {
|
||||||
|
state = 'code';
|
||||||
|
recordSignificant(char);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsxUrlSeparator = options.jsx && char === '/' && next === '/' &&
|
||||||
|
jsxExpressionDepth === 0 &&
|
||||||
|
(output.endsWith('http:') ||
|
||||||
|
output.endsWith('https:') ||
|
||||||
|
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1)) &&
|
||||||
|
/^[\w.-]+\.[A-Za-z]{2,}(?=[:/?#\s<]|$)/.test(content.slice(i + 2))));
|
||||||
|
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
||||||
|
previousSignificant === lastSignificant &&
|
||||||
|
antePreviousSignificant !== lastSignificant;
|
||||||
|
if (char === '/' && next === '/' && jsxUrlSeparator) {
|
||||||
|
output += '//';
|
||||||
|
i++;
|
||||||
|
recordSignificant('/');
|
||||||
|
recordSignificant('/');
|
||||||
|
} else if (char === '/' && next === '/') {
|
||||||
|
output += ' ';
|
||||||
|
i++;
|
||||||
|
state = 'line-comment';
|
||||||
|
} else if (char === '/' && next === '*') {
|
||||||
|
output += ' ';
|
||||||
|
i++;
|
||||||
|
state = 'block-comment';
|
||||||
|
} else if (templateExpressionDepths.length && char === '{') {
|
||||||
|
output += char;
|
||||||
|
templateExpressionDepths[templateExpressionDepths.length - 1]++;
|
||||||
|
braceKinds.push(braceKind());
|
||||||
|
if (jsxExpressionDepth) jsxExpressionDepth++;
|
||||||
|
recordSignificant(char);
|
||||||
|
} else if (templateExpressionDepths.length && char === '}') {
|
||||||
|
output += char;
|
||||||
|
const depthIndex = templateExpressionDepths.length - 1;
|
||||||
|
templateExpressionDepths[depthIndex]--;
|
||||||
|
lastClosedBraceKind = braceKinds.pop() || '';
|
||||||
|
if (jsxExpressionDepth) jsxExpressionDepth--;
|
||||||
|
recordSignificant(char);
|
||||||
|
if (templateExpressionDepths[depthIndex] === 0) {
|
||||||
|
templateExpressionDepths.pop();
|
||||||
|
state = 'template';
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
char === '/' &&
|
||||||
|
(!lastSignificant ||
|
||||||
|
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
||||||
|
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
||||||
|
) {
|
||||||
|
output += char;
|
||||||
|
state = 'regex';
|
||||||
|
regexCharClass = false;
|
||||||
|
} else {
|
||||||
|
output += char;
|
||||||
|
const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 &&
|
||||||
|
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1, -1)) ||
|
||||||
|
isInsideOpeningJsxTag(output.slice(0, -1)));
|
||||||
|
if (char === '{') braceKinds.push(braceKind(startsJsxExpression));
|
||||||
|
else if (char === '}') lastClosedBraceKind = braceKinds.pop() || '';
|
||||||
|
if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth++;
|
||||||
|
else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth--;
|
||||||
|
recordSignificant(char);
|
||||||
|
if (char === "'") state = 'single-quote';
|
||||||
|
else if (char === '"') state = 'double-quote';
|
||||||
|
else if (char === '`') state = 'template';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCssComments(content) {
|
||||||
|
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
|
||||||
|
}
|
||||||
|
|
||||||
function firstOverusedGoogleFont(text) {
|
function firstOverusedGoogleFont(text) {
|
||||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||||
}
|
}
|
||||||
@@ -528,18 +743,198 @@ function extractStyleBlocks(content, ext) {
|
|||||||
|
|
||||||
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
||||||
|
|
||||||
|
function findQuotedStringEnd(content, start, quote) {
|
||||||
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||||
|
if (content[cursor] === '\\') cursor++;
|
||||||
|
else if (content[cursor] === quote) return cursor;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRegexLiteralEnd(content, start) {
|
||||||
|
let inCharacterClass = false;
|
||||||
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||||
|
const char = content[cursor];
|
||||||
|
if (char === '\\') {
|
||||||
|
cursor++;
|
||||||
|
} else if (char === '[') {
|
||||||
|
inCharacterClass = true;
|
||||||
|
} else if (char === ']') {
|
||||||
|
inCharacterClass = false;
|
||||||
|
} else if (char === '/' && !inCharacterClass) {
|
||||||
|
while (/[A-Za-z]/.test(content[cursor + 1] || '')) cursor++;
|
||||||
|
return cursor;
|
||||||
|
} else if (char === '\n' || char === '\r') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTemplateExpressionEnd(content, start) {
|
||||||
|
let depth = 1;
|
||||||
|
let lastSignificant = '';
|
||||||
|
let previousSignificant = '';
|
||||||
|
let antePreviousSignificant = '';
|
||||||
|
let currentWord = '';
|
||||||
|
let currentWordPrefix = '';
|
||||||
|
let wordSeparated = false;
|
||||||
|
let lastClosedBraceKind = '';
|
||||||
|
const braceKinds = [];
|
||||||
|
|
||||||
|
const braceKind = () => (
|
||||||
|
lastSignificant === ')' ||
|
||||||
|
lastSignificant === ';' ||
|
||||||
|
lastSignificant === '}' ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
||||||
|
? 'block'
|
||||||
|
: 'expression'
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordSignificant = (char) => {
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
wordSeparated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isWordChar = /[\w$]/.test(char);
|
||||||
|
if (isWordChar && (wordSeparated || !currentWord)) {
|
||||||
|
currentWord = '';
|
||||||
|
currentWordPrefix = lastSignificant;
|
||||||
|
} else if (!isWordChar) {
|
||||||
|
currentWordPrefix = '';
|
||||||
|
}
|
||||||
|
wordSeparated = false;
|
||||||
|
antePreviousSignificant = previousSignificant;
|
||||||
|
previousSignificant = lastSignificant;
|
||||||
|
lastSignificant = char;
|
||||||
|
currentWord = isWordChar ? currentWord + char : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let cursor = start; cursor < content.length; cursor++) {
|
||||||
|
const char = content[cursor];
|
||||||
|
const next = content[cursor + 1];
|
||||||
|
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
||||||
|
previousSignificant === lastSignificant &&
|
||||||
|
antePreviousSignificant !== lastSignificant;
|
||||||
|
if (char === "'" || char === '"') {
|
||||||
|
cursor = findQuotedStringEnd(content, cursor, char);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
recordSignificant(')');
|
||||||
|
} else if (char === '/' && next === '/') {
|
||||||
|
const lineEnd = content.indexOf('\n', cursor + 2);
|
||||||
|
if (lineEnd === -1) return -1;
|
||||||
|
cursor = lineEnd;
|
||||||
|
} else if (char === '/' && next === '*') {
|
||||||
|
const commentEnd = content.indexOf('*/', cursor + 2);
|
||||||
|
if (commentEnd === -1) return -1;
|
||||||
|
cursor = commentEnd + 1;
|
||||||
|
} else if (
|
||||||
|
char === '/' &&
|
||||||
|
(!lastSignificant ||
|
||||||
|
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
||||||
|
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
||||||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
||||||
|
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
||||||
|
) {
|
||||||
|
cursor = findRegexLiteralEnd(content, cursor);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
recordSignificant(')');
|
||||||
|
} else if (char === '`') {
|
||||||
|
cursor = findTemplateLiteralEnd(content, cursor);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
recordSignificant(')');
|
||||||
|
} else if (char === '{') {
|
||||||
|
depth++;
|
||||||
|
braceKinds.push(braceKind());
|
||||||
|
recordSignificant(char);
|
||||||
|
} else if (char === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return cursor;
|
||||||
|
lastClosedBraceKind = braceKinds.pop() || '';
|
||||||
|
recordSignificant(char);
|
||||||
|
} else {
|
||||||
|
recordSignificant(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTemplateLiteralEnd(content, start) {
|
||||||
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
||||||
|
const char = content[cursor];
|
||||||
|
if (char === '\\') {
|
||||||
|
cursor++;
|
||||||
|
} else if (char === '`') {
|
||||||
|
return cursor;
|
||||||
|
} else if (char === '$' && content[cursor + 1] === '{') {
|
||||||
|
cursor = findTemplateExpressionEnd(content, cursor + 2);
|
||||||
|
if (cursor === -1) return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCSSinJSTemplates(content) {
|
||||||
|
const templates = [];
|
||||||
|
const tagRe = /\b(?:styled(?:\.\w+|\([^)]+\))|css)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = tagRe.exec(content)) !== null) {
|
||||||
|
let cursor = match.index + match[0].length;
|
||||||
|
while (/\s/.test(content[cursor] || '')) cursor++;
|
||||||
|
|
||||||
|
if (content[cursor] === '<') {
|
||||||
|
let depth = 0;
|
||||||
|
while (cursor < content.length) {
|
||||||
|
const char = content[cursor];
|
||||||
|
if (char === '<') depth++;
|
||||||
|
else if (char === '>' && content[cursor - 1] !== '=') depth--;
|
||||||
|
cursor++;
|
||||||
|
if (depth === 0) break;
|
||||||
|
}
|
||||||
|
if (depth !== 0) continue;
|
||||||
|
while (/\s/.test(content[cursor] || '')) cursor++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content[cursor] !== '`') continue;
|
||||||
|
const contentStart = cursor + 1;
|
||||||
|
cursor = findTemplateLiteralEnd(content, cursor);
|
||||||
|
if (cursor === -1) continue;
|
||||||
|
|
||||||
|
templates.push({
|
||||||
|
tagStart: match.index,
|
||||||
|
contentStart,
|
||||||
|
contentEnd: cursor,
|
||||||
|
});
|
||||||
|
tagRe.lastIndex = cursor + 1;
|
||||||
|
}
|
||||||
|
return templates;
|
||||||
|
}
|
||||||
|
|
||||||
function extractCSSinJS(content, ext) {
|
function extractCSSinJS(content, ext) {
|
||||||
ext = ext.toLowerCase();
|
ext = ext.toLowerCase();
|
||||||
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
||||||
const blocks = [];
|
return findCSSinJSTemplates(content).map((template) => {
|
||||||
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
|
const before = content.substring(0, template.tagStart);
|
||||||
let m;
|
|
||||||
while ((m = re.exec(content)) !== null) {
|
|
||||||
const before = content.substring(0, m.index);
|
|
||||||
const startLine = before.split('\n').length;
|
const startLine = before.split('\n').length;
|
||||||
blocks.push({ content: m[1], startLine });
|
return {
|
||||||
|
content: content.slice(template.contentStart, template.contentEnd),
|
||||||
|
startLine,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCssInJsComments(content, ext) {
|
||||||
|
if (!CSS_IN_JS_EXTENSIONS.has(ext.toLowerCase())) return content;
|
||||||
|
const templates = findCSSinJSTemplates(content);
|
||||||
|
let output = '';
|
||||||
|
let cursor = 0;
|
||||||
|
for (const template of templates) {
|
||||||
|
output += content.slice(cursor, template.contentStart);
|
||||||
|
output += stripCssComments(content.slice(template.contentStart, template.contentEnd));
|
||||||
|
cursor = template.contentEnd;
|
||||||
}
|
}
|
||||||
return blocks;
|
return output + content.slice(cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
|
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
|
||||||
@@ -627,8 +1022,12 @@ function runTextContentAnalyzers(content, filePath, options = {}) {
|
|||||||
function detectText(content, filePath, options = {}) {
|
function detectText(content, filePath, options = {}) {
|
||||||
const profile = options?.profile;
|
const profile = options?.profile;
|
||||||
const findings = [];
|
const findings = [];
|
||||||
const lines = content.split('\n');
|
|
||||||
const ext = extFromFilePath(filePath);
|
const ext = extFromFilePath(filePath);
|
||||||
|
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
|
||||||
|
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
|
||||||
|
}) : content;
|
||||||
|
const source = stripCssInJsComments(commentStrippedSource, ext);
|
||||||
|
const lines = source.split('\n');
|
||||||
|
|
||||||
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
||||||
// Enable block context for CSS files where related properties span multiple lines
|
// Enable block context for CSS files where related properties span multiple lines
|
||||||
@@ -661,8 +1060,8 @@ function detectText(content, filePath, options = {}) {
|
|||||||
phase: 'source',
|
phase: 'source',
|
||||||
ruleId: 'codex-grid-background',
|
ruleId: 'codex-grid-background',
|
||||||
target: filePath,
|
target: filePath,
|
||||||
}, () => scanCssTextForGridBackground(content).map(hit => {
|
}, () => scanCssTextForGridBackground(source).map(hit => {
|
||||||
const line = content.substring(0, hit.index).split('\n').length;
|
const line = source.substring(0, hit.index).split('\n').length;
|
||||||
return finding('codex-grid-background', filePath, hit.snippet, line);
|
return finding('codex-grid-background', filePath, hit.snippet, line);
|
||||||
})));
|
})));
|
||||||
|
|
||||||
@@ -698,16 +1097,17 @@ function detectText(content, filePath, options = {}) {
|
|||||||
phase: 'extract',
|
phase: 'extract',
|
||||||
ruleId: 'css-in-js',
|
ruleId: 'css-in-js',
|
||||||
target: filePath,
|
target: filePath,
|
||||||
}, () => extractCSSinJS(content, ext))
|
}, () => extractCSSinJS(source, ext))
|
||||||
: extractCSSinJS(content, ext);
|
: extractCSSinJS(source, ext);
|
||||||
for (const block of cssJsBlocks) {
|
for (const block of cssJsBlocks) {
|
||||||
const blockLines = block.content.split('\n');
|
const blockContent = stripCssComments(block.content);
|
||||||
|
const blockLines = blockContent.split('\n');
|
||||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||||
profile,
|
profile,
|
||||||
phase: 'css-in-js',
|
phase: 'css-in-js',
|
||||||
}));
|
}));
|
||||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
|
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 1));
|
||||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
|
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.designSystem) {
|
if (options?.designSystem) {
|
||||||
|
|||||||
@@ -26,11 +26,26 @@ const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
|
|||||||
const SCANNABLE_EXTENSIONS = new Set([
|
const SCANNABLE_EXTENSIONS = new Set([
|
||||||
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
||||||
'.jsx', '.tsx', '.js', '.ts',
|
'.jsx', '.tsx', '.js', '.ts',
|
||||||
'.vue', '.svelte', '.astro',
|
'.vue', '.svelte', '.astro', '.blade.php',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
||||||
|
|
||||||
|
function hasScannableExtension(filename) {
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
|
||||||
|
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||||
|
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORT_SPECIFIER_PATTERNS = [
|
||||||
|
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
|
||||||
|
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
|
||||||
|
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||||
|
];
|
||||||
|
|
||||||
function walkDir(dir) {
|
function walkDir(dir) {
|
||||||
const files = [];
|
const files = [];
|
||||||
let entries;
|
let entries;
|
||||||
@@ -40,7 +55,7 @@ function walkDir(dir) {
|
|||||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||||
const full = path.join(dir, entry.name);
|
const full = path.join(dir, entry.name);
|
||||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||||
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
|
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
@@ -75,26 +90,11 @@ function buildImportGraph(files) {
|
|||||||
const dir = path.dirname(file);
|
const dir = path.dirname(file);
|
||||||
const imports = new Set();
|
const imports = new Set();
|
||||||
|
|
||||||
// ES imports: import ... from '...' and import '...'
|
for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
|
||||||
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
|
for (const match of content.matchAll(pattern)) {
|
||||||
let m;
|
const resolved = resolveImport(match[1], dir, fileSet);
|
||||||
while ((m = esRe.exec(content)) !== null) {
|
if (resolved) imports.add(resolved);
|
||||||
const resolved = resolveImport(m[1], dir, fileSet);
|
}
|
||||||
if (resolved) imports.add(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSS @import
|
|
||||||
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
|
|
||||||
while ((m = cssRe.exec(content)) !== null) {
|
|
||||||
const resolved = resolveImport(m[1], dir, fileSet);
|
|
||||||
if (resolved) imports.add(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SCSS @use / @forward
|
|
||||||
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
|
|
||||||
while ((m = scssRe.exec(content)) !== null) {
|
|
||||||
const resolved = resolveImport(m[1], dir, fileSet);
|
|
||||||
if (resolved) imports.add(resolved);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
graph.set(file, imports);
|
graph.set(file, imports);
|
||||||
@@ -203,6 +203,7 @@ export {
|
|||||||
SKIP_DIRS,
|
SKIP_DIRS,
|
||||||
SCANNABLE_EXTENSIONS,
|
SCANNABLE_EXTENSIONS,
|
||||||
HTML_EXTENSIONS,
|
HTML_EXTENSIONS,
|
||||||
|
hasScannableExtension,
|
||||||
walkDir,
|
walkDir,
|
||||||
resolveImport,
|
resolveImport,
|
||||||
buildImportGraph,
|
buildImportGraph,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* node hook-admin.mjs off # set enabled: false
|
* node hook-admin.mjs off # set enabled: false
|
||||||
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
|
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
|
||||||
* node hook-admin.mjs ignore-rule overused-font --all-values
|
* node hook-admin.mjs ignore-rule overused-font --all-values
|
||||||
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
|
* node hook-admin.mjs ignore-file <glob> [--shared|--local] # append to ignoreFiles
|
||||||
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
||||||
* node hook-admin.mjs ignore-value <rule> <value> --local
|
* node hook-admin.mjs ignore-value <rule> <value> --local
|
||||||
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
|
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
|
||||||
@@ -166,7 +166,7 @@ function readRawConfigFile(filePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
|
||||||
|
|
||||||
function hookSection(unified) {
|
function hookSection(unified) {
|
||||||
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
|
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
|
||||||
@@ -200,6 +200,15 @@ function stripDetectorKeys(raw) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickDetectorKeys(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||||
|
const out = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Write hook runtime config under `hook`, leaving detector filters in
|
// Write hook runtime config under `hook`, leaving detector filters in
|
||||||
// `detector` and preserving sibling keys such as updateCheck.
|
// `detector` and preserving sibling keys such as updateCheck.
|
||||||
function writeHookConfig(cwd, hookConfig, opts = {}) {
|
function writeHookConfig(cwd, hookConfig, opts = {}) {
|
||||||
@@ -207,10 +216,19 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
|
|||||||
if (opts.local) ensureHookGitExcludes(cwd);
|
if (opts.local) ensureHookGitExcludes(cwd);
|
||||||
const existingRaw = readRawConfigFile(filePath).raw;
|
const existingRaw = readRawConfigFile(filePath).raw;
|
||||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||||
const existingHook = stripDetectorKeys(hookSection(existing));
|
const existingHookSection = hookSection(existing);
|
||||||
|
const existingHook = stripDetectorKeys(existingHookSection);
|
||||||
|
const legacyDetector = pickDetectorKeys(existingHookSection);
|
||||||
// Merge over the existing hook object so fields the merge helpers don't manage
|
// Merge over the existing hook object so fields the merge helpers don't manage
|
||||||
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
|
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
|
||||||
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
||||||
|
if (Object.keys(legacyDetector).length > 0) {
|
||||||
|
const existingDetector = detectorSection(existing) || {};
|
||||||
|
next.detector = {
|
||||||
|
...existingDetector,
|
||||||
|
...mergeDetectorConfig(existingDetector, mergeDetectorConfig(legacyDetector)),
|
||||||
|
};
|
||||||
|
}
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
||||||
return filePath;
|
return filePath;
|
||||||
@@ -222,10 +240,14 @@ function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
|
|||||||
const existingRaw = readRawConfigFile(filePath).raw;
|
const existingRaw = readRawConfigFile(filePath).raw;
|
||||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||||
const nextHook = stripDetectorKeys(hookSection(existing));
|
const nextHook = stripDetectorKeys(hookSection(existing));
|
||||||
const existingDetector = mergeDetectorConfig(detectorSection(existing));
|
const existingDetectorSection = detectorSection(existing) || {};
|
||||||
|
const existingDetector = mergeDetectorConfig(existingDetectorSection);
|
||||||
const next = {
|
const next = {
|
||||||
...existing,
|
...existing,
|
||||||
detector: mergeDetectorConfig(detectorConfig, existingDetector),
|
detector: {
|
||||||
|
...existingDetectorSection,
|
||||||
|
...mergeDetectorConfig(detectorConfig, existingDetector),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
|
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
|
||||||
else delete next.hook;
|
else delete next.hook;
|
||||||
@@ -259,12 +281,18 @@ function mergeDetectorConfig(existing, seed = null) {
|
|||||||
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
|
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
|
||||||
out.designSystem = { ...seed.designSystem };
|
out.designSystem = { ...seed.designSystem };
|
||||||
}
|
}
|
||||||
|
if (seed?.advisoryRules === 'include' || seed?.advisoryRules === 'exclude') {
|
||||||
|
out.advisoryRules = seed.advisoryRules;
|
||||||
|
}
|
||||||
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
|
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
|
||||||
out.designSystem = {
|
out.designSystem = {
|
||||||
...(out.designSystem || {}),
|
...(out.designSystem || {}),
|
||||||
enabled: base.designSystem.enabled === false ? false : true,
|
enabled: base.designSystem.enabled === false ? false : true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (base.advisoryRules === 'include' || base.advisoryRules === 'exclude') {
|
||||||
|
out.advisoryRules = base.advisoryRules;
|
||||||
|
}
|
||||||
if (Array.isArray(base.ignoreRules)) {
|
if (Array.isArray(base.ignoreRules)) {
|
||||||
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
|
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
|
||||||
}
|
}
|
||||||
@@ -558,12 +586,44 @@ function addIgnoreRule(cwd, args) {
|
|||||||
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
|
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addIgnoreFile(cwd, glob) {
|
function parseIgnoreFileArgs(args) {
|
||||||
|
const positionals = [];
|
||||||
|
let shared = false;
|
||||||
|
let local = false;
|
||||||
|
|
||||||
|
for (const raw of args) {
|
||||||
|
const arg = String(raw || '');
|
||||||
|
if (arg === '--shared') {
|
||||||
|
shared = true;
|
||||||
|
} else if (arg === '--local') {
|
||||||
|
local = true;
|
||||||
|
} else if (arg === '--reason' || arg.startsWith('--reason=')) {
|
||||||
|
throw new Error('--reason is not supported for ignore-file because detector.ignoreFiles stores globs only; use ignore-value when a documented rule-specific exception fits');
|
||||||
|
} else if (arg.startsWith('--')) {
|
||||||
|
throw new Error(`Unknown ignore-file flag: ${arg}`);
|
||||||
|
} else {
|
||||||
|
positionals.push(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shared && local) throw new Error('Pass only one scope flag: --shared or --local');
|
||||||
|
if (positionals.length > 1) throw new Error('Pass exactly one glob to ignore-file');
|
||||||
|
|
||||||
|
return {
|
||||||
|
glob: positionals[0],
|
||||||
|
local,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIgnoreFile(cwd, args) {
|
||||||
|
const parsed = parseIgnoreFileArgs(args);
|
||||||
|
const glob = parsed.glob;
|
||||||
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
|
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
|
||||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
|
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local: parsed.local }));
|
||||||
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
||||||
writeDetectorConfig(cwd, config);
|
const target = writeDetectorConfig(cwd, config, { local: parsed.local });
|
||||||
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
|
const scope = parsed.local ? 'local detector.ignoreFiles' : 'shared detector.ignoreFiles';
|
||||||
|
return `Added "${glob}" to ${scope} (${path.relative(cwd, target) || target}). Current: ${config.ignoreFiles.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
|
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
|
||||||
@@ -727,7 +787,7 @@ function main() {
|
|||||||
case 'on': out = setEnabled(cwd, true); break;
|
case 'on': out = setEnabled(cwd, true); break;
|
||||||
case 'off': out = setEnabled(cwd, false); break;
|
case 'off': out = setEnabled(cwd, false); break;
|
||||||
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
|
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
|
||||||
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
|
case 'ignore-file': out = addIgnoreFile(cwd, rest); break;
|
||||||
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
|
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
|
||||||
case 'reset': out = reset(cwd); break;
|
case 'reset': out = reset(cwd); break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
designSystemOptions,
|
designSystemOptions,
|
||||||
filterFindings,
|
filterFindings,
|
||||||
isNativePlatform,
|
isNativePlatform,
|
||||||
|
isScanTargetInsideProject,
|
||||||
loadDetector,
|
loadDetector,
|
||||||
matchConfiguredExtension,
|
matchConfiguredExtension,
|
||||||
matchesAnyGlob,
|
matchesAnyGlob,
|
||||||
@@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readExistingProjectFile(filePath, cwd) {
|
function readExistingProjectFile(filePath, cwd) {
|
||||||
if (!isInsideProject(filePath, cwd)) return null;
|
if (!isScanTargetInsideProject(filePath, cwd)) return null;
|
||||||
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
|
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
@@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) {
|
|||||||
const source = shellCopyPaths(command)?.source;
|
const source = shellCopyPaths(command)?.source;
|
||||||
if (!source) return '';
|
if (!source) return '';
|
||||||
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
|
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
|
||||||
if (!isInsideProject(sourcePath, cwd)) return '';
|
if (!isScanTargetInsideProject(sourcePath, cwd)) return '';
|
||||||
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
|
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(sourcePath);
|
const stat = fs.statSync(sourcePath);
|
||||||
@@ -328,15 +329,6 @@ function relativePath(filePath, cwd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInsideProject(filePath, cwd) {
|
|
||||||
try {
|
|
||||||
const rel = path.relative(cwd, filePath);
|
|
||||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||||
@@ -414,7 +406,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
|
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
|
||||||
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
||||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||||
|
|
||||||
|
|||||||
@@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve a path to its canonical (symlink-free) form. When the path does
|
||||||
|
// not exist yet — the before-edit hook gates proposed Writes — canonicalize
|
||||||
|
// the nearest existing ancestor and re-append the remainder, so a new file
|
||||||
|
// under a symlinked root still compares equal to its canonical project.
|
||||||
|
// Memoized: the hook runs as a fresh process per tool event, so the cache
|
||||||
|
// amounts to once-per-event work — the scan loops re-check the same project
|
||||||
|
// root for every target file. The cap only matters to long-lived importers
|
||||||
|
// like the test runner.
|
||||||
|
const canonicalPathCache = new Map();
|
||||||
|
const CANONICAL_PATH_CACHE_MAX = 1024;
|
||||||
|
|
||||||
|
function canonicalPath(p) {
|
||||||
|
const resolved = path.resolve(p);
|
||||||
|
if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved);
|
||||||
|
let canonical = resolved;
|
||||||
|
let dir = resolved;
|
||||||
|
const tail = [];
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir);
|
||||||
|
break;
|
||||||
|
} catch { /* keep climbing */ }
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) break;
|
||||||
|
tail.unshift(path.basename(dir));
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear();
|
||||||
|
canonicalPathCache.set(resolved, canonical);
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Containment gate shared by the before-edit hook and both scan passes. A
|
||||||
|
// session routinely touches files that belong to no project or to a
|
||||||
|
// different one — harness scratchpad dirs under the system temp root,
|
||||||
|
// sibling checkouts, one-off throwaway HTML — and findings against those are
|
||||||
|
// judged with THIS project's config and DESIGN.md palette, which is never
|
||||||
|
// right. Skip them (audit reason: outside-project). Paths are canonicalized
|
||||||
|
// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the
|
||||||
|
// comparison.
|
||||||
|
export function isScanTargetInsideProject(filePath, projectCwd) {
|
||||||
|
if (!filePath || !projectCwd) return false;
|
||||||
|
return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd));
|
||||||
|
}
|
||||||
|
|
||||||
export function parseStaticStyleImports(content, fromFile, projectCwd) {
|
export function parseStaticStyleImports(content, fromFile, projectCwd) {
|
||||||
if (!content || typeof content !== 'string') return [];
|
if (!content || typeof content !== 'string') return [];
|
||||||
const dir = path.dirname(fromFile);
|
const dir = path.dirname(fromFile);
|
||||||
@@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|||||||
lastSkip = 'file-missing';
|
lastSkip = 'file-missing';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!isScanTargetInsideProject(filePath, projectCwd)) {
|
||||||
|
lastSkip = 'outside-project';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
|
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
|
||||||
if (maxFileBytes > 0) {
|
if (maxFileBytes > 0) {
|
||||||
@@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
|||||||
const relForMatch = relativize(filePath, projectCwd);
|
const relForMatch = relativize(filePath, projectCwd);
|
||||||
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
|
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
|
||||||
if (!fs.existsSync(filePath)) continue;
|
if (!fs.existsSync(filePath)) continue;
|
||||||
|
// Caches written before this gate existed can still hold out-of-project
|
||||||
|
// paths, so the Stop pass re-checks containment rather than trusting
|
||||||
|
// the per-edit pass to have filtered them.
|
||||||
|
if (!isScanTargetInsideProject(filePath, projectCwd)) continue;
|
||||||
|
|
||||||
scanned += 1;
|
scanned += 1;
|
||||||
let content = '';
|
let content = '';
|
||||||
|
|||||||
@@ -50,9 +50,36 @@ export function normalizeConceptForm(value) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateConceptEntry(concept, { existingForms = new Map() } = {}) {
|
export function validateConceptEntry(concept, { existingForms = new Map(), axes = null } = {}) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const id = concept?.id || '(unknown)';
|
const id = concept?.id || '(unknown)';
|
||||||
|
|
||||||
|
// Recorded aesthetic axis values. Optional, and absent means the value is
|
||||||
|
// inferred from the system rules instead. Some axes cannot be inferred at all:
|
||||||
|
// depth's keyword probe matched worlds that said "no cast shadow anywhere",
|
||||||
|
// and motion and colour strategy describe properties the rules never state, so
|
||||||
|
// a wave that assigns those has to record them or the assignment is lost.
|
||||||
|
// Validated against the axes definition when the caller supplies it, because a
|
||||||
|
// typo would read as "unrecorded" and silently fall back to a probe that is
|
||||||
|
// known not to work.
|
||||||
|
if (concept?.axes !== undefined && concept.axes !== null) {
|
||||||
|
if (typeof concept.axes !== 'object' || Array.isArray(concept.axes)) {
|
||||||
|
errors.push(`concept ${id} axes must be an object of axis id to value id`);
|
||||||
|
} else if (axes) {
|
||||||
|
const byId = new Map((axes.axes || []).map(axis => [axis.id, axis]));
|
||||||
|
for (const [axisId, valueId] of Object.entries(concept.axes)) {
|
||||||
|
const axis = byId.get(axisId);
|
||||||
|
if (!axis) {
|
||||||
|
errors.push(`concept ${id} names unknown axis "${axisId}"`);
|
||||||
|
} else if (!(axis.values || []).some(value => value.id === valueId)) {
|
||||||
|
errors.push(
|
||||||
|
`concept ${id} axis "${axisId}" has unknown value "${valueId}" `
|
||||||
|
+ `(expected one of ${(axis.values || []).map(v => v.id).join(', ')})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
|
||||||
errors.push(`invalid concept id: ${String(concept?.id)}`);
|
errors.push(`invalid concept id: ${String(concept?.id)}`);
|
||||||
}
|
}
|
||||||
@@ -82,6 +109,18 @@ export function validateConceptEntry(concept, { existingForms = new Map() } = {}
|
|||||||
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||||
errors.push(`concept ${id} must have exactly three structural tags`);
|
errors.push(`concept ${id} must have exactly three structural tags`);
|
||||||
}
|
}
|
||||||
|
// The slop this world in particular is at risk of. Optional, because 541
|
||||||
|
// entries predate it and none of them are wrong for lacking it. A world built
|
||||||
|
// from posters is at risk of shouting and one built from instruments is at
|
||||||
|
// risk of dead greys; a global detector cannot know which, and the author can.
|
||||||
|
if (concept?.avoid !== undefined) {
|
||||||
|
if (!Array.isArray(concept.avoid)
|
||||||
|
|| concept.avoid.length < 2
|
||||||
|
|| concept.avoid.length > 3
|
||||||
|
|| concept.avoid.some(item => typeof item !== 'string' || item.trim().length < 12 || item.trim().length > 160)) {
|
||||||
|
errors.push(`concept ${id} avoid must be two or three negations of 12–160 characters`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!Array.isArray(concept?.system)
|
if (!Array.isArray(concept?.system)
|
||||||
|| concept.system.length !== SYSTEM_PREFIXES.length
|
|| concept.system.length !== SYSTEM_PREFIXES.length
|
||||||
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
||||||
|
|||||||
@@ -2,15 +2,20 @@
|
|||||||
// the live-mode design-system panel can render. Deterministic, dependency-free.
|
// the live-mode design-system panel can render. Deterministic, dependency-free.
|
||||||
//
|
//
|
||||||
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
|
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
|
||||||
// (prose with six canonical H2 sections). When frontmatter is present, it's
|
// (prose with eight canonical H2 sections). When frontmatter is present, it's
|
||||||
// exposed on `model.frontmatter` alongside the prose-scraped sections;
|
// exposed on `model.frontmatter` alongside the prose-scraped sections;
|
||||||
// consumers can prefer frontmatter values and fall back to prose.
|
// consumers can prefer frontmatter values and fall back to prose.
|
||||||
|
|
||||||
|
// Array order is also match precedence: matchCanonicalSection's keyword-contained
|
||||||
|
// pass returns the first entry a heading contains, so reordering this changes
|
||||||
|
// which section an ambiguous heading resolves to.
|
||||||
const CANONICAL_SECTIONS = [
|
const CANONICAL_SECTIONS = [
|
||||||
'Overview',
|
'Overview',
|
||||||
'Colors',
|
'Colors',
|
||||||
'Typography',
|
'Typography',
|
||||||
|
'Layout',
|
||||||
'Elevation',
|
'Elevation',
|
||||||
|
'Shapes',
|
||||||
'Components',
|
'Components',
|
||||||
"Do's and Don'ts",
|
"Do's and Don'ts",
|
||||||
];
|
];
|
||||||
@@ -115,10 +120,71 @@ function stripInlineYamlComment(s) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YAML double-quoted scalars process backslash escapes. Stripping the outer
|
||||||
|
// quotes without unescaping leaves them in place, so a nested font family like
|
||||||
|
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
|
||||||
|
// keeps its literal backslashes and never matches the same family in CSS.
|
||||||
|
// The full YAML 1.2 double-quote escape set (spec section 5.7).
|
||||||
|
const YAML_SIMPLE_ESCAPES = {
|
||||||
|
'0': '\0',
|
||||||
|
a: '\x07',
|
||||||
|
b: '\b',
|
||||||
|
t: '\t',
|
||||||
|
n: '\n',
|
||||||
|
v: '\v',
|
||||||
|
f: '\f',
|
||||||
|
r: '\r',
|
||||||
|
e: '\x1b',
|
||||||
|
' ': ' ',
|
||||||
|
'"': '"',
|
||||||
|
'/': '/',
|
||||||
|
'\\': '\\',
|
||||||
|
N: '\u0085',
|
||||||
|
_: '\u00a0',
|
||||||
|
L: '\u2028',
|
||||||
|
P: '\u2029',
|
||||||
|
};
|
||||||
|
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
|
||||||
|
|
||||||
|
function unescapeYamlDoubleQuoted(body) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
const ch = body[i];
|
||||||
|
if (ch !== '\\' || i === body.length - 1) {
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = body[i + 1];
|
||||||
|
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
|
||||||
|
out += YAML_SIMPLE_ESCAPES[next];
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
|
||||||
|
// literal rather than corrupting the rest of the scalar.
|
||||||
|
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
|
||||||
|
if (hexLen) {
|
||||||
|
const hex = body.slice(i + 2, i + 2 + hexLen);
|
||||||
|
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
|
||||||
|
if (codePoint >= 0 && codePoint <= 0x10ffff) {
|
||||||
|
out += String.fromCodePoint(codePoint);
|
||||||
|
i += 1 + hexLen;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function parseScalar(raw) {
|
function parseScalar(raw) {
|
||||||
const s = raw.trim();
|
const s = raw.trim();
|
||||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||||
return s.slice(1, -1);
|
return unescapeYamlDoubleQuoted(s.slice(1, -1));
|
||||||
|
}
|
||||||
|
// Single-quoted YAML escapes only the quote itself, by doubling it.
|
||||||
|
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
|
||||||
|
return s.slice(1, -1).split("''").join("'");
|
||||||
}
|
}
|
||||||
if (s === 'true') return true;
|
if (s === 'true') return true;
|
||||||
if (s === 'false') return false;
|
if (s === 'false') return false;
|
||||||
@@ -330,17 +396,16 @@ function extractOverview(section) {
|
|||||||
if (!section) return null;
|
if (!section) return null;
|
||||||
const text = section.lines.join('\n');
|
const text = section.lines.join('\n');
|
||||||
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
|
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
|
||||||
const keyChars = [];
|
|
||||||
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
|
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
|
||||||
if (keyCharMatch) {
|
const keyChars = keyCharMatch
|
||||||
for (const line of keyCharMatch[1].split('\n')) {
|
? collectBullets(keyCharMatch[1].split('\n')).map((bullet) => stripBold(bullet.trim()))
|
||||||
const m = line.match(/^\s*[-*]\s+(.+)$/);
|
: [];
|
||||||
if (m) keyChars.push(stripBold(m[1].trim()));
|
const prose = keyCharMatch
|
||||||
}
|
? text.slice(0, keyCharMatch.index) + text.slice(keyCharMatch.index + keyCharMatch[0].length)
|
||||||
}
|
: text;
|
||||||
|
|
||||||
// Philosophy paragraphs: everything that isn't a rule header or key-char block
|
// Philosophy paragraphs: everything that isn't a rule header or key-char block
|
||||||
const paragraphs = collectParagraphs(section.lines).filter(
|
const paragraphs = collectParagraphs(prose.split('\n')).filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
!p.startsWith('**Creative North Star') &&
|
!p.startsWith('**Creative North Star') &&
|
||||||
!p.startsWith('**Key Characteristics')
|
!p.startsWith('**Key Characteristics')
|
||||||
@@ -602,11 +667,19 @@ function parseTypeBullet(bullet) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractElevation(section) {
|
function extractGuidance(section) {
|
||||||
if (!section) return null;
|
if (!section) return null;
|
||||||
const subs = splitSubsections(section.lines);
|
const subs = splitSubsections(section.lines);
|
||||||
|
return {
|
||||||
|
subtitle: section.subtitle,
|
||||||
|
description: collectParagraphs(subs[0].lines).join(' ') || null,
|
||||||
|
rules: extractNamedRules(section.lines),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const description = collectParagraphs(subs[0].lines).join(' ') || null;
|
function extractElevation(section) {
|
||||||
|
const guidance = extractGuidance(section);
|
||||||
|
if (!guidance) return null;
|
||||||
|
|
||||||
const shadows = [];
|
const shadows = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -631,12 +704,7 @@ function extractElevation(section) {
|
|||||||
for (const inline of extractInlineShadows(b)) dedupe(inline);
|
for (const inline of extractInlineShadows(b)) dedupe(inline);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { ...guidance, shadows };
|
||||||
subtitle: section.subtitle,
|
|
||||||
description,
|
|
||||||
shadows,
|
|
||||||
rules: extractNamedRules(section.lines),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractInlineShadows(text) {
|
function extractInlineShadows(text) {
|
||||||
@@ -768,6 +836,15 @@ function extractDosDonts(section) {
|
|||||||
|
|
||||||
// ---------- Coverage assessment ----------
|
// ---------- Coverage assessment ----------
|
||||||
|
|
||||||
|
// Sections whose model is description-plus-rules only (see extractGuidance).
|
||||||
|
const guidanceCoverage = (guidance) =>
|
||||||
|
guidance
|
||||||
|
? {
|
||||||
|
description: Boolean(guidance.description),
|
||||||
|
rules: guidance.rules.length,
|
||||||
|
}
|
||||||
|
: 'missing';
|
||||||
|
|
||||||
function assessCoverage(model) {
|
function assessCoverage(model) {
|
||||||
const report = {};
|
const report = {};
|
||||||
|
|
||||||
@@ -796,6 +873,8 @@ function assessCoverage(model) {
|
|||||||
}
|
}
|
||||||
: 'missing';
|
: 'missing';
|
||||||
|
|
||||||
|
report.layout = guidanceCoverage(model.layout);
|
||||||
|
|
||||||
report.elevation = model.elevation
|
report.elevation = model.elevation
|
||||||
? {
|
? {
|
||||||
shadows: model.elevation.shadows.length,
|
shadows: model.elevation.shadows.length,
|
||||||
@@ -804,6 +883,8 @@ function assessCoverage(model) {
|
|||||||
}
|
}
|
||||||
: 'missing';
|
: 'missing';
|
||||||
|
|
||||||
|
report.shapes = guidanceCoverage(model.shapes);
|
||||||
|
|
||||||
report.components = model.components
|
report.components = model.components
|
||||||
? {
|
? {
|
||||||
count: model.components.components.length,
|
count: model.components.components.length,
|
||||||
@@ -833,7 +914,9 @@ export function parseDesignMd(md) {
|
|||||||
overview: extractOverview(sections['Overview']),
|
overview: extractOverview(sections['Overview']),
|
||||||
colors: extractColors(sections['Colors']),
|
colors: extractColors(sections['Colors']),
|
||||||
typography: extractTypography(sections['Typography']),
|
typography: extractTypography(sections['Typography']),
|
||||||
|
layout: extractGuidance(sections['Layout']),
|
||||||
elevation: extractElevation(sections['Elevation']),
|
elevation: extractElevation(sections['Elevation']),
|
||||||
|
shapes: extractGuidance(sections['Shapes']),
|
||||||
components: extractComponents(sections['Components']),
|
components: extractComponents(sections['Components']),
|
||||||
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
|
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
export function browserOpenCommand(url, {
|
||||||
|
platform = process.platform,
|
||||||
|
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
|
||||||
|
} = {}) {
|
||||||
|
if (platform === 'darwin') return { command: 'open', args: [url] };
|
||||||
|
if (platform === 'win32') return { command: comspec, args: ['/c', 'start', '', url] };
|
||||||
|
return { command: 'xdg-open', args: [url] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSystemBrowser(url, {
|
||||||
|
platform = process.platform,
|
||||||
|
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
|
||||||
|
spawnImpl = spawn,
|
||||||
|
} = {}) {
|
||||||
|
const { command, args } = browserOpenCommand(url, { platform, comspec });
|
||||||
|
try {
|
||||||
|
const child = spawnImpl(command, args, { stdio: 'ignore', detached: true });
|
||||||
|
child.on('error', () => {});
|
||||||
|
child.unref();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user