mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8685fd75e6 | ||
|
|
a2c00964ca | ||
|
|
94b7f34f6e | ||
|
|
6fe900dbb4 | ||
|
|
2c8816a3ce | ||
|
|
632912b5ae | ||
|
|
85d82c0afc | ||
|
|
5b585c0885 | ||
|
|
187790826d | ||
|
|
40b5151237 | ||
|
|
1bcdf80f91 | ||
|
|
d3f4cc8f4b | ||
|
|
5211bdf4b1 | ||
|
|
bfafc7dbcb | ||
|
|
0bc8643b51 | ||
|
|
4fb66a15e7 | ||
|
|
f3df3ffe40 | ||
|
|
405998ad51 | ||
|
|
4cc9578a3d | ||
|
|
8c59bc7fb0 | ||
|
|
52139bc8d3 | ||
|
|
ac64a2fe12 | ||
|
|
61a2d230fd | ||
|
|
2cfd60765a | ||
|
|
205643cf7a | ||
|
|
1130d00ebc | ||
|
|
ee1442d7af | ||
|
|
33367e2297 | ||
|
|
5b5fdc12f3 | ||
|
|
fc442be223 | ||
|
|
2b1804deaa | ||
|
|
e92bf2b774 | ||
|
|
a74bb22e3a | ||
|
|
3a37bcb7d3 | ||
|
|
f1e8d4e70e |
@@ -4,7 +4,7 @@ description: Use when the user wants to design, redesign, shape, critique, audit
|
||||
version: 4.1.2
|
||||
---
|
||||
|
||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as a award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||
|
||||
Core principles:
|
||||
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
|
||||
@@ -14,7 +14,7 @@ Core principles:
|
||||
## Setup
|
||||
|
||||
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .agents/skills/impeccable/scripts/...` command in this skill and its references, and `.agents/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
|
||||
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
|
||||
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
|
||||
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
|
||||
|
||||
## How to design
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
### Purpose
|
||||
|
||||
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
|
||||
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
|
||||
|
||||
### Hard Invariants
|
||||
|
||||
@@ -97,7 +97,7 @@ Codex failure accounting: final Run Notes must include target slug, ignore list,
|
||||
|
||||
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
|
||||
|
||||
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
|
||||
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
|
||||
|
||||
Codex final-answer note: `$impeccable critique` produces a report artifact, so the final chat response should intentionally exceed the usual concise close-out style. Do not title the final response "Critique Summary" unless the user explicitly asked for a summary.
|
||||
|
||||
@@ -219,7 +219,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
|
||||
node .agents/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
|
||||
```
|
||||
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
|
||||
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
|
||||
|
||||
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ The first argument is the action. Defaults to `status`.
|
||||
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
|
||||
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
|
||||
| `ignore-value <id> "*" --file <glob> [--file <glob>...]` | Turn one rule off in matching files only, leaving it active everywhere else. Repeat `--file`, or use `--file=<glob>` / `--files=<glob>`. A bare `"*"` with no `--file` is refused: use `ignore-rule <id>` if you really mean project-wide. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue, and remove the hook's entries from every provider manifest `on` installs, the committed Copilot file included (a team-shared `settings.json` that `on` never writes is never touched). |
|
||||
|
||||
## Flow
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
|
||||
4. Run `node .agents/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
|
||||
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
|
||||
|
||||
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
|
||||
@@ -68,16 +68,20 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su
|
||||
|
||||
## 5. Record the decision
|
||||
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
Before code, record the chosen direction as a development-only contract under `## Direction contract` in the relevant surface brief. A direction contract is durable route or artifact strategy, so create or update the brief even when no other surface strategy needs persistence. Keep the contract to six short blocks and 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The surface brief is the reminder later agents reload across edits and sessions: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
|
||||
Never copy the direction contract into implementation source or any browser-delivered artifact. This includes HTML or framework comments, hidden DOM, `<template>` elements, `data-*` attributes, rendered JSX or TSX output, serialized props or state, React Server Component payloads, client bundles, metadata or JSON-LD, accessibility-only text, and files served beside the artifact. A compiler or optimizer removing development metadata is not a safety boundary. Reviewers and documenters receive the contract from the surface brief.
|
||||
|
||||
On a new or replacement world, DESIGN.md is written at finish, from the built world, by the shipped documenter (section 7); a rulebook written before the build gets defended against reality instead of describing it, and hands the design-system detector an unstable target. A new world shipped with no DESIGN.md is still an incomplete run. An ordinary extension does not rewrite DESIGN.md.
|
||||
|
||||
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
|
||||
Read the existing surface brief before updating it:
|
||||
|
||||
`node .agents/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
|
||||
|
||||
`node .agents/skills/impeccable/scripts/surface-brief.mjs write <primary-target> <body-file> [related-target ...]`
|
||||
|
||||
After writing, read the brief once more and verify that all six contract blocks and the seed key are present before building.
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
On a comp-led build, whenever any image generation is available (a harness-native tool or the API fallback context.mjs reports), the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options put before the user for approval, the chosen card's decision comp plus two variations. This step is proven to produce the most compositional and ambitious work. On a code-led build the comp round is skipped by contract, never by drift: the ambition it would have carried lives in the direction contract's FIRST VIEWPORT block and named signature interaction, and the finish reviewer audits those promises in behavior.
|
||||
|
||||
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
|
||||
If a prior critique exists, use it as one input:
|
||||
|
||||
```bash
|
||||
node .agents/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
|
||||
node .agents/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
|
||||
```
|
||||
|
||||
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
|
||||
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
|
||||
|
||||
## 3. Triage
|
||||
|
||||
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
|
||||
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
|
||||
|
||||
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
|
||||
|
||||
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
|
||||
|
||||
```bash
|
||||
node .agents/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
|
||||
```
|
||||
|
||||
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
|
||||
|
||||
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
|
||||
|
||||
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
|
||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `$impeccable critique <surface>` is a strong default.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
approvedPoolRevision,
|
||||
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
|
||||
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
export function sameMainModulePath(left, right, platform = process.platform) {
|
||||
if (platform !== 'win32') return left === right;
|
||||
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
|
||||
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
|
||||
// invoked path. Compare real paths so a linked skill still runs its CLI,
|
||||
// normalizing the drive-letter casing that Windows junctions can change.
|
||||
return sameMainModulePath(
|
||||
realpathSync(process.argv[1]),
|
||||
realpathSync(fileURLToPath(import.meta.url))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const args = process.argv.slice(2);
|
||||
const fromIdx = args.indexOf('--from');
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* CLI entry points (called from skill instructions):
|
||||
* node critique-storage.mjs slug <resolved-target>
|
||||
* node critique-storage.mjs write <slug> <snapshot-body-file>
|
||||
* node critique-storage.mjs latest <slug>
|
||||
* node critique-storage.mjs latest <slug> [--json]
|
||||
* node critique-storage.mjs trend <slug> [limit]
|
||||
* node critique-storage.mjs close <resolved-target> <snapshot-file>
|
||||
*
|
||||
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
|
||||
* markdown file; the model reads it directly with its file-read tool. This
|
||||
@@ -27,6 +28,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
import { slugFromTarget } from './lib/target-slug.mjs';
|
||||
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
|
||||
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an exact content fingerprint for a local file target. URLs and
|
||||
* non-files return null because their content is not available here.
|
||||
*
|
||||
* The fingerprint deliberately describes bytes, not Git state or mtimes:
|
||||
* critique often assesses an uncommitted file, and a later polish run should
|
||||
* inherit that backlog when the bytes are unchanged regardless of staging.
|
||||
*/
|
||||
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
|
||||
if (!target || /^https?:\/\//i.test(target)) return null;
|
||||
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
|
||||
}
|
||||
|
||||
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
|
||||
if (!target || typeof target !== 'string') return null;
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
try {
|
||||
const url = new URL(target);
|
||||
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
||||
return `url:${url.origin}${pathname}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const filePath = resolveLocalTargetPath(target, { cwd });
|
||||
return filePath ? `file:${filePath}` : null;
|
||||
}
|
||||
|
||||
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
|
||||
const filePath = resolveLocalTargetPath(target, { cwd });
|
||||
if (!filePath) return null;
|
||||
try {
|
||||
if (!fs.statSync(filePath).isFile()) return null;
|
||||
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
|
||||
* keys read back by readTrend(). `body` is the human-readable critique
|
||||
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
|
||||
const dir = getCritiqueDir(cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const timestamp = nowFilenameStamp(now);
|
||||
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
|
||||
// Spread `meta` first so internally computed `timestamp` and `slug`
|
||||
// always win. Otherwise a caller-supplied meta blob (parsed from the
|
||||
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
|
||||
// filename in disagreement with its frontmatter and corrupting trends.
|
||||
const front = serializeFrontmatter({ ...meta, timestamp, slug });
|
||||
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
|
||||
return filePath;
|
||||
const contents = `${front}\n${body.trim()}\n`;
|
||||
|
||||
// A second critique can finish in the same UTC second. Use exclusive
|
||||
// creation and a fixed-width suffix so concurrent writers cannot replace
|
||||
// history and lexical ordering still keeps collision entries newest.
|
||||
for (let collision = 0; collision <= 9999; collision += 1) {
|
||||
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
|
||||
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
|
||||
try {
|
||||
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
|
||||
}
|
||||
|
||||
function serializeFrontmatter(obj) {
|
||||
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
|
||||
try { value = JSON.parse(value); } catch { /* leave as-is */ }
|
||||
} else if (/^-?\d+$/.test(value)) {
|
||||
value = Number(value);
|
||||
} else if (value === 'true' || value === 'false') {
|
||||
value = value === 'true';
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
|
||||
/**
|
||||
* Return snapshot files matching `suffix`, sorted oldest → newest.
|
||||
*/
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
|
||||
|
||||
function listSnapshots(suffix, cwd) {
|
||||
const dir = getCritiqueDir(cwd);
|
||||
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
|
||||
.map((f) => path.join(dir, f));
|
||||
}
|
||||
|
||||
function readLatestSnapshotMatching(suffix, cwd) {
|
||||
const filePath = listSnapshots(suffix, cwd).at(-1);
|
||||
function readSnapshot(filePath) {
|
||||
if (!filePath) return null;
|
||||
const body = fs.readFileSync(filePath, 'utf-8');
|
||||
return { path: filePath, body, meta: parseFrontmatter(body) };
|
||||
}
|
||||
|
||||
function snapshotTargetIdentity(snapshot) {
|
||||
const targetPath = snapshot?.meta.target_path;
|
||||
return snapshot?.meta.target_identity
|
||||
|| (targetPath ? `file:${targetPath}` : null);
|
||||
}
|
||||
|
||||
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
|
||||
}
|
||||
|
||||
function readNewestSnapshotForIdentity(
|
||||
slug,
|
||||
targetIdentity,
|
||||
{ cwd = process.cwd() } = {},
|
||||
) {
|
||||
const matches = listSnapshots(`__${slug}.md`, cwd)
|
||||
.map(readSnapshot)
|
||||
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
|
||||
return matches.at(-1) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
||||
* to find its fix backlog when the slug matches.
|
||||
*/
|
||||
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
|
||||
const latest = readNewestSnapshot(slug, { cwd });
|
||||
return latest?.meta.closed === true ? null : latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one exact snapshot closed without deleting the score history consumed
|
||||
* by `trend`. Exact identity matters: a newer critique may land after polish
|
||||
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
|
||||
* may be the absolute path returned by readLatestSnapshot() or the basename
|
||||
* emitted by `latest --json`. Returns the path marked closed, or null.
|
||||
*/
|
||||
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
|
||||
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
|
||||
const dir = path.resolve(getCritiqueDir(cwd));
|
||||
const snapshotPath = path.isAbsolute(snapshotFile)
|
||||
? path.resolve(snapshotFile)
|
||||
: path.resolve(dir, snapshotFile);
|
||||
const filename = path.basename(snapshotPath);
|
||||
if (
|
||||
path.dirname(snapshotPath) !== dir
|
||||
|| !SNAPSHOT_FILENAME.test(filename)
|
||||
) return null;
|
||||
|
||||
let snapshot;
|
||||
try {
|
||||
if (!fs.lstatSync(snapshotPath).isFile()) return null;
|
||||
snapshot = readSnapshot(snapshotPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!snapshot || snapshot.meta.closed === true) return null;
|
||||
const closedBody = snapshot.body.replace(
|
||||
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
|
||||
'$1\nclosed: true$2',
|
||||
);
|
||||
if (closedBody === snapshot.body) {
|
||||
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
|
||||
}
|
||||
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
|
||||
return snapshot.path;
|
||||
}
|
||||
|
||||
/** Return the most recent snapshot across all targets, or null. */
|
||||
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching('.md', cwd);
|
||||
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
|
||||
const identifiedSlugs = new Set(
|
||||
snapshots
|
||||
.filter((snapshot) => snapshotTargetIdentity(snapshot))
|
||||
.map((snapshot) => snapshot.meta.slug),
|
||||
);
|
||||
const latestByTarget = new Map();
|
||||
for (const snapshot of snapshots) {
|
||||
if (!snapshot?.meta.slug) continue;
|
||||
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
|
||||
// one. Keep each known identity's latest open/closed state independent so
|
||||
// closing one target cannot hide another target's live backlog. Once a
|
||||
// slug has any identity-aware snapshot, its older legacy records are no
|
||||
// longer independently routable and must not resurface as zombie work.
|
||||
const targetIdentity = snapshotTargetIdentity(snapshot);
|
||||
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
|
||||
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
|
||||
latestByTarget.set(streamKey, snapshot);
|
||||
}
|
||||
return [...latestByTarget.values()]
|
||||
.filter((snapshot) => snapshot.meta.closed !== true)
|
||||
.sort((a, b) => a.path.localeCompare(b.path))
|
||||
.at(-1) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
||||
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
|
||||
// callers never have to run the slug step separately. Anything containing a
|
||||
// path or URL marker is resolved through slugFromTarget.
|
||||
function isReadySlug(value) {
|
||||
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
|
||||
}
|
||||
|
||||
function coerceSlug(value) {
|
||||
if (!value) return null;
|
||||
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
|
||||
if (isReadySlug(value)) return value;
|
||||
return slugFromTarget(value);
|
||||
}
|
||||
|
||||
@@ -181,14 +322,124 @@ function main(argv) {
|
||||
if (metaArg) {
|
||||
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
|
||||
}
|
||||
// The helper, not caller-provided metadata, owns the target fingerprint.
|
||||
// This makes the snapshot describe the exact file bytes critique saw.
|
||||
delete meta.target_fingerprint;
|
||||
delete meta.target_path;
|
||||
delete meta.target_identity;
|
||||
const targetIdentity = resolveTargetIdentity(slugArg);
|
||||
if (targetIdentity) meta.target_identity = targetIdentity;
|
||||
const targetFingerprint = fingerprintTarget(slugArg);
|
||||
if (targetFingerprint) {
|
||||
meta.target_fingerprint = targetFingerprint;
|
||||
meta.target_path = resolveLocalTargetPath(slugArg);
|
||||
}
|
||||
const out = writeSnapshot({ slug, meta, body: raw });
|
||||
process.stdout.write(`${out}\n`);
|
||||
return;
|
||||
}
|
||||
case 'latest': {
|
||||
const latest = readLatestSnapshot(coerceSlug(args[0]));
|
||||
if (!latest) { process.exit(2); }
|
||||
process.stdout.write(latest.body);
|
||||
const target = args[0];
|
||||
const format = args[1];
|
||||
const slug = coerceSlug(target);
|
||||
if (!slug || (format && format !== '--json')) {
|
||||
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const targetFingerprint = fingerprintTarget(target);
|
||||
const targetPath = resolveLocalTargetPath(target);
|
||||
const targetIdentity = resolveTargetIdentity(target);
|
||||
const readySlug = isReadySlug(target);
|
||||
const newestForSlug = readNewestSnapshot(slug);
|
||||
if (!newestForSlug) { process.exit(2); }
|
||||
|
||||
// Concrete targets select the newest snapshot for their exact identity,
|
||||
// not merely the newest filename for a lossy slug. This keeps distinct
|
||||
// targets such as foo/bar and foo-bar from hiding each other's backlog.
|
||||
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
|
||||
let latest = exactSnapshot;
|
||||
if (!latest && !readySlug) {
|
||||
// Legacy snapshots have no identity. Preserve their old explicit
|
||||
// path/URL behavior only when no known target identity was selected.
|
||||
latest = readNewestSnapshotForIdentity(slug, null);
|
||||
}
|
||||
if (!latest) latest = newestForSlug;
|
||||
if (latest.meta.closed === true) { process.exit(2); }
|
||||
|
||||
const recordedTargetPath = latest.meta.target_path;
|
||||
const recordedTargetIdentity = snapshotTargetIdentity(latest);
|
||||
const matchingIdentity = recordedTargetIdentity === targetIdentity;
|
||||
|
||||
// Bare slugs remain a supported lookup mode, including for URL
|
||||
// snapshots. But when a same-named local file exists, the request is
|
||||
// ambiguous unless that exact file owns the snapshot identity.
|
||||
if (readySlug && !recordedTargetIdentity) {
|
||||
process.stderr.write(
|
||||
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
|
||||
process.stderr.write(
|
||||
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const concreteTarget = !readySlug || matchingIdentity;
|
||||
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
|
||||
process.exit(2);
|
||||
}
|
||||
const concreteLocalTarget = concreteTarget && targetPath;
|
||||
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
|
||||
closeSnapshot(latest.path);
|
||||
process.exit(2);
|
||||
}
|
||||
if (format === '--json') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
snapshot_file: path.basename(latest.path),
|
||||
body: latest.body,
|
||||
}, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(latest.body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'close': {
|
||||
const [slugArg, snapshotFile, ...extra] = args;
|
||||
const slug = coerceSlug(slugArg);
|
||||
if (!slug || !snapshotFile || extra.length > 0) {
|
||||
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
if (
|
||||
path.basename(snapshotFile) !== snapshotFile
|
||||
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|
||||
|| !snapshotFile.endsWith(`__${slug}.md`)
|
||||
) process.exit(2);
|
||||
|
||||
// A slug and filename are not enough to prove ownership because two
|
||||
// distinct targets can normalize to the same slug. Modern snapshots
|
||||
// carry a canonical identity, so require the supplied resolved target
|
||||
// to match it before allowing the exact snapshot to be closed. Legacy
|
||||
// snapshots without identity retain their historical close behavior.
|
||||
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
|
||||
let snapshot;
|
||||
try {
|
||||
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
|
||||
snapshot = readSnapshot(snapshotPath);
|
||||
} catch {
|
||||
process.exit(2);
|
||||
}
|
||||
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
|
||||
if (
|
||||
recordedTargetIdentity
|
||||
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
|
||||
) process.exit(2);
|
||||
|
||||
const closed = closeSnapshot(snapshotFile);
|
||||
if (!closed) { process.exit(2); }
|
||||
process.stdout.write(`${closed}\n`);
|
||||
return;
|
||||
}
|
||||
case 'trend': {
|
||||
@@ -197,7 +448,7 @@ function main(argv) {
|
||||
return;
|
||||
}
|
||||
default:
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
|
||||
return String(value || '')
|
||||
.replace(/\s*\/\s*/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(token => token.trim())
|
||||
// var() fallbacks leave the closing parenthesis on the final token. Strip
|
||||
// it before length resolution so `8px)` is not treated as unitless 8rem.
|
||||
.map(token => token.trim().replace(/\)+$/, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,13 +70,27 @@ function isBrandFontOnOwnDomain(font) {
|
||||
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||
}
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
// Overused-font primary selection skips only CSS generics so a system stack
|
||||
// keeps the system face as primary; GENERIC_FONTS still includes platform
|
||||
// faces for design-system/serif resolution.
|
||||
const CSS_GENERIC_FONTS = new Set([
|
||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
...CSS_GENERIC_FONTS,
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
]);
|
||||
|
||||
function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
|
||||
return String(fontFamily || '')
|
||||
.split(',')
|
||||
.map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
|
||||
.find(f => f && !skip.has(f)) || null;
|
||||
}
|
||||
|
||||
// WCAG large text thresholds are defined in points: 18pt normal text and
|
||||
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
@@ -1591,7 +1605,7 @@ function checkIconTile(opts) {
|
||||
function resolveSerif(fontFamily) {
|
||||
if (!fontFamily) return { primary: null, isSerif: false };
|
||||
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
|
||||
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
|
||||
if (!primary) return { primary: null, isSerif: false };
|
||||
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
|
||||
if (tokens.includes('serif')) return { primary, isSerif: true };
|
||||
@@ -5190,8 +5204,7 @@ function checkTypography() {
|
||||
const style = getComputedStyle(el);
|
||||
const ff = style.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (!primary) continue;
|
||||
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
||||
totalTextElements++;
|
||||
@@ -5436,8 +5449,7 @@ function checkPageTypography(doc, win) {
|
||||
if (rule.type !== 1) continue;
|
||||
const ff = rule.style?.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
@@ -5456,11 +5468,10 @@ function checkPageTypography(doc, win) {
|
||||
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
let fm;
|
||||
while ((fm = ffRe.exec(html)) !== null) {
|
||||
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
||||
if (f && !GENERIC_FONTS.has(f)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
const primary = primaryFontFace(fm[1]);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { OVERUSED_FONTS, primaryFontFace } from '../../shared/constants.mjs';
|
||||
import {
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
@@ -51,9 +51,7 @@ function checkStaticPageTypography(document, window) {
|
||||
for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) {
|
||||
const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasText) continue;
|
||||
const ff = window.getComputedStyle(el).fontFamily || '';
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(window.getComputedStyle(el).fontFamily);
|
||||
if (!primary) continue;
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
isBrandFontOnOwnDomain,
|
||||
primaryFontFace,
|
||||
} from '../shared/constants.mjs';
|
||||
import {
|
||||
CSS_NAMED_COLORS,
|
||||
@@ -331,7 +332,7 @@ function checkIconTile(opts) {
|
||||
function resolveSerif(fontFamily) {
|
||||
if (!fontFamily) return { primary: null, isSerif: false };
|
||||
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
|
||||
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
|
||||
if (!primary) return { primary: null, isSerif: false };
|
||||
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
|
||||
if (tokens.includes('serif')) return { primary, isSerif: true };
|
||||
@@ -3930,8 +3931,7 @@ function checkTypography() {
|
||||
const style = getComputedStyle(el);
|
||||
const ff = style.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (!primary) continue;
|
||||
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
||||
totalTextElements++;
|
||||
@@ -4176,8 +4176,7 @@ function checkPageTypography(doc, win) {
|
||||
if (rule.type !== 1) continue;
|
||||
const ff = rule.style?.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
@@ -4196,11 +4195,10 @@ function checkPageTypography(doc, win) {
|
||||
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
let fm;
|
||||
while ((fm = ffRe.exec(html)) !== null) {
|
||||
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
||||
if (f && !GENERIC_FONTS.has(f)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
const primary = primaryFontFace(fm[1]);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,13 +56,27 @@ function isBrandFontOnOwnDomain(font) {
|
||||
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||
}
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
// Overused-font primary selection skips only CSS generics so a system stack
|
||||
// keeps the system face as primary; GENERIC_FONTS still includes platform
|
||||
// faces for design-system/serif resolution.
|
||||
const CSS_GENERIC_FONTS = new Set([
|
||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
...CSS_GENERIC_FONTS,
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
]);
|
||||
|
||||
function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
|
||||
return String(fontFamily || '')
|
||||
.split(',')
|
||||
.map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
|
||||
.find(f => f && !skip.has(f)) || null;
|
||||
}
|
||||
|
||||
// WCAG large text thresholds are defined in points: 18pt normal text and
|
||||
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
@@ -104,6 +118,7 @@ export {
|
||||
BRAND_FONT_DOMAINS,
|
||||
isBrandFontOnOwnDomain,
|
||||
GENERIC_FONTS,
|
||||
primaryFontFace,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
|
||||
@@ -21,22 +21,24 @@ import zlib from 'node:zlib';
|
||||
const KEYWORD = 'impeccable:prompt';
|
||||
const args = process.argv.slice(2);
|
||||
const file = args.find(a => !a.startsWith('--'));
|
||||
const readMode = args.includes('--read');
|
||||
const scanMode = args.includes('--scan');
|
||||
const argOf = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : null; };
|
||||
|
||||
function promptOf(imagePath) {
|
||||
const b = fs.readFileSync(imagePath);
|
||||
let prompt = null;
|
||||
if (b.length > 8 && b.readUInt32BE(0) === 0x89504e47) prompt = readPngText(b);
|
||||
else if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8) prompt = readJpegCom(b);
|
||||
function imageType(buffer) {
|
||||
if (buffer.length > 8 && buffer.readUInt32BE(0) === 0x89504e47) return 'png';
|
||||
if (buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8) return 'jpeg';
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPrompt(imagePath, buffer = fs.readFileSync(imagePath)) {
|
||||
const type = imageType(buffer);
|
||||
let prompt = type === 'png' ? parsePng(buffer).prompt : type === 'jpeg' ? readJpegCom(buffer) : null;
|
||||
if (prompt == null && fs.existsSync(`${imagePath}.json`)) {
|
||||
try { prompt = JSON.parse(fs.readFileSync(`${imagePath}.json`, 'utf8')).prompt ?? null; } catch { /* stays null */ }
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
if (scanMode) {
|
||||
if (args.includes('--scan')) {
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
if (targets.length === 0) { console.error('embed-prompt: --scan needs at least one directory'); process.exit(1); }
|
||||
const RASTER = /\.(png|jpe?g|webp)$/i;
|
||||
@@ -59,7 +61,7 @@ if (scanMode) {
|
||||
}
|
||||
let missing = 0;
|
||||
for (const raster of rasters) {
|
||||
if (promptOf(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
|
||||
if (readPrompt(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
|
||||
}
|
||||
console.log(`SCAN: ${rasters.length} raster${rasters.length === 1 ? '' : 's'}, ${missing} missing`);
|
||||
process.exit(missing > 0 ? 3 : 0);
|
||||
@@ -68,8 +70,7 @@ if (scanMode) {
|
||||
if (!file || !fs.existsSync(file)) { console.error('embed-prompt: image file required'); process.exit(1); }
|
||||
|
||||
const buf = fs.readFileSync(file);
|
||||
const isPng = buf.length > 8 && buf.readUInt32BE(0) === 0x89504e47;
|
||||
const isJpeg = buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8;
|
||||
const type = imageType(buf);
|
||||
|
||||
const crcTable = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
@@ -87,22 +88,26 @@ function pngChunk(type, data) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function readPngText(b) {
|
||||
let off = 8;
|
||||
while (off + 12 <= b.length) {
|
||||
const len = b.readUInt32BE(off);
|
||||
const type = b.toString('ascii', off + 4, off + 8);
|
||||
if (type === 'tEXt' || type === 'zTXt') {
|
||||
const data = b.subarray(off + 8, off + 8 + len);
|
||||
const nul = data.indexOf(0);
|
||||
if (nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD) {
|
||||
if (type === 'tEXt') return data.toString('utf8', nul + 1);
|
||||
return zlib.inflateSync(data.subarray(nul + 2)).toString('utf8');
|
||||
}
|
||||
function parsePng(buffer) {
|
||||
const chunks = [];
|
||||
let prompt = null;
|
||||
let offset = 8;
|
||||
while (offset + 12 <= buffer.length) {
|
||||
const length = buffer.readUInt32BE(offset);
|
||||
const type = buffer.toString('ascii', offset + 4, offset + 8);
|
||||
const data = buffer.subarray(offset + 8, offset + 8 + length);
|
||||
const nul = data.indexOf(0);
|
||||
const promptChunk = (type === 'tEXt' || type === 'zTXt')
|
||||
&& nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
|
||||
if (prompt == null && promptChunk) {
|
||||
prompt = type === 'tEXt'
|
||||
? data.toString('utf8', nul + 1)
|
||||
: zlib.inflateSync(data.subarray(nul + 2)).toString('utf8');
|
||||
}
|
||||
off += 12 + len;
|
||||
chunks.push({ offset, type, promptChunk, bytes: buffer.subarray(offset, offset + 12 + length) });
|
||||
offset += 12 + length;
|
||||
}
|
||||
return null;
|
||||
return { chunks, prompt };
|
||||
}
|
||||
|
||||
function readJpegCom(b) {
|
||||
@@ -121,48 +126,34 @@ function readJpegCom(b) {
|
||||
}
|
||||
|
||||
const sidecar = `${file}.json`;
|
||||
if (readMode) {
|
||||
let prompt = null;
|
||||
if (isPng) prompt = readPngText(buf);
|
||||
else if (isJpeg) prompt = readJpegCom(buf);
|
||||
if (prompt == null && fs.existsSync(sidecar)) {
|
||||
try { prompt = JSON.parse(fs.readFileSync(sidecar, 'utf8')).prompt ?? null; } catch { /* fall through */ }
|
||||
}
|
||||
if (args.includes('--read')) {
|
||||
const prompt = readPrompt(file, buf);
|
||||
if (prompt == null) { console.error('embed-prompt: no embedded prompt found'); process.exit(2); }
|
||||
console.log(prompt);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const prompt = argOf('--prompt') ?? (argOf('--prompt-file') ? fs.readFileSync(argOf('--prompt-file'), 'utf8') : null);
|
||||
const promptFile = argOf('--prompt-file');
|
||||
const prompt = argOf('--prompt') ?? (promptFile ? fs.readFileSync(promptFile, 'utf8') : null);
|
||||
if (!prompt) { console.error('embed-prompt: --prompt or --prompt-file required'); process.exit(1); }
|
||||
|
||||
if (isPng) {
|
||||
if (type === 'png') {
|
||||
// Insert (or replace) our tEXt chunk immediately before IEND.
|
||||
const iend = buf.indexOf(Buffer.from('IEND', 'ascii')) - 4;
|
||||
const { chunks, prompt: existingPrompt } = parsePng(buf);
|
||||
const iend = chunks.find((chunk) => chunk.type === 'IEND')?.offset ?? -1;
|
||||
if (iend < 8) { console.error('embed-prompt: malformed PNG'); process.exit(1); }
|
||||
// Drop any existing chunk with our keyword to keep embedding idempotent.
|
||||
let body = buf.subarray(8, iend);
|
||||
const existing = readPngText(buf);
|
||||
if (existing != null) {
|
||||
const parts = [];
|
||||
let off = 8;
|
||||
while (off + 12 <= buf.length && off < iend + 12) {
|
||||
const len = buf.readUInt32BE(off);
|
||||
const type = buf.toString('ascii', off + 4, off + 8);
|
||||
const chunk = buf.subarray(off, off + 12 + len);
|
||||
const data = buf.subarray(off + 8, off + 8 + len);
|
||||
const nul = data.indexOf(0);
|
||||
const ours = (type === 'tEXt' || type === 'zTXt') && nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
|
||||
if (!ours && type !== 'IEND') parts.push(chunk);
|
||||
off += 12 + len;
|
||||
}
|
||||
body = Buffer.concat(parts).subarray(8 * 0); // parts exclude signature
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), pngChunk('IEND', Buffer.alloc(0))]));
|
||||
} else {
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, iend), pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), buf.subarray(iend)]));
|
||||
}
|
||||
const replacing = existingPrompt != null;
|
||||
const body = replacing
|
||||
? Buffer.concat(chunks
|
||||
.filter((chunk) => chunk.offset < iend && !chunk.promptChunk)
|
||||
.map((chunk) => chunk.bytes))
|
||||
: buf.subarray(8, iend);
|
||||
const promptChunk = pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')]));
|
||||
const end = replacing ? pngChunk('IEND', Buffer.alloc(0)) : buf.subarray(iend);
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, promptChunk, end]));
|
||||
console.log(`EMBEDDED: ${file} (png tEXt, ${prompt.length} chars)`);
|
||||
} else if (isJpeg) {
|
||||
} else if (type === 'jpeg') {
|
||||
const seg = Buffer.from(`${KEYWORD}\0${prompt}`, 'utf8');
|
||||
if (seg.length + 2 > 0xffff) { console.error('embed-prompt: prompt too long for a JPEG segment'); process.exit(1); }
|
||||
const com = Buffer.alloc(4 + seg.length);
|
||||
|
||||
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
|
||||
// The prompt travels with the asset: embedded in the file itself (EXIF-class
|
||||
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
|
||||
// plus a sidecar for anything that indexes rather than opens the image.
|
||||
let embedded = false;
|
||||
try {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
embedded = !result.error && result.status === 0;
|
||||
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
|
||||
} catch { /* embedding is best-effort */ }
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
|
||||
if (plateCtx && plateCtx.chroma) {
|
||||
const frac = await keyChroma(out, plateCtx.chroma);
|
||||
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
|
||||
|
||||
@@ -770,9 +770,22 @@ function reset(cwd) {
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return removed.length
|
||||
? `Reset design hook config and cache (removed: ${removed.join(', ')}).`
|
||||
: 'No hook config or cache to remove. Already at defaults.';
|
||||
// `on` writes three things: config, consent, and hook entries in the
|
||||
// provider manifests. Reset must undo all three (issue #512): a leftover
|
||||
// manifest entry kept invoking the hook after the config that said "off"
|
||||
// was deleted. Local destRel only, since `on` never writes the team-shared
|
||||
// sharedDestRel. No skill-folder gate: a reset mid-uninstall (skill files
|
||||
// gone, manifest still wired) is the case that most needs the prune.
|
||||
const pruned = [];
|
||||
for (const target of HOOK_MANIFEST_TARGETS) {
|
||||
try {
|
||||
if (pruneImpeccableHookFromManifest(path.join(cwd, target.destRel))) pruned.push(target.provider);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const parts = [];
|
||||
if (removed.length) parts.push(`Reset design hook config and cache (removed: ${removed.join(', ')}).`);
|
||||
if (pruned.length) parts.push(`Removed hook entries from: ${pruned.join(', ')}.`);
|
||||
return parts.length ? parts.join(' ') : 'No hook config or cache to remove. Already at defaults.';
|
||||
}
|
||||
|
||||
function main() {
|
||||
|
||||
@@ -329,47 +329,37 @@ function stripBold(s) {
|
||||
function extractNamedRules(lines) {
|
||||
const rules = [];
|
||||
const seen = new Set();
|
||||
const addRule = (name, body, { allowDuplicate = false } = {}) => {
|
||||
const key = name.toLowerCase();
|
||||
if (!allowDuplicate && seen.has(key)) return;
|
||||
seen.add(key);
|
||||
rules.push({ name, body });
|
||||
};
|
||||
|
||||
// Style A (Impeccable): "**The X Rule.** body body body" — can span lines.
|
||||
const joined = lines.join('\n');
|
||||
const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g;
|
||||
const inlineMatches = [];
|
||||
let m;
|
||||
while ((m = inlineStart.exec(joined)) !== null) {
|
||||
inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex });
|
||||
}
|
||||
const inlineMatches = [...joined.matchAll(/\*\*(The [^*]+?Rule)\.\*\*/g)];
|
||||
for (let i = 0; i < inlineMatches.length; i++) {
|
||||
const mm = inlineMatches[i];
|
||||
const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;
|
||||
const match = inlineMatches[i];
|
||||
const bodyEnd = inlineMatches[i + 1]?.index ?? joined.length;
|
||||
const body = joined
|
||||
.slice(mm.end, bodyEnd)
|
||||
.slice(match.index + match[0].length, bodyEnd)
|
||||
.replace(/\n##[^\n]*$/s, '')
|
||||
.replace(/\n###[^\n]*$/s, '')
|
||||
.trim();
|
||||
const name = stripBold(mm.name).trim();
|
||||
seen.add(name.toLowerCase());
|
||||
rules.push({ name, body: stripBold(body) });
|
||||
// Preserve the inline format's historical behavior: repeated inline rules
|
||||
// remain visible, while the later heading and bullet formats dedupe.
|
||||
addRule(stripBold(match[1]).trim(), stripBold(body), { allowDuplicate: true });
|
||||
}
|
||||
|
||||
// Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the
|
||||
// bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const h3 = lines[i].match(/^###\s+(.+?)\s*$/);
|
||||
if (!h3) continue;
|
||||
const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim();
|
||||
for (const subsection of splitSubsections(lines).slice(1)) {
|
||||
const headerName = stripBold(subsection.name).replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;
|
||||
if (seen.has(headerName.toLowerCase())) continue;
|
||||
|
||||
const bodyLines = [];
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
if (/^##\s|^###\s/.test(lines[j])) break;
|
||||
bodyLines.push(lines[j]);
|
||||
}
|
||||
const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim();
|
||||
if (body) {
|
||||
seen.add(headerName.toLowerCase());
|
||||
rules.push({ name: headerName, body });
|
||||
}
|
||||
const body = stripBold(subsection.lines.join('\n').replace(/\n+/g, ' ')).trim();
|
||||
if (body) addRule(headerName, body);
|
||||
}
|
||||
|
||||
// Style C (Stitch bullet form): "* **The Layering Principle:** body"
|
||||
@@ -379,9 +369,7 @@ function extractNamedRules(lines) {
|
||||
if (!mm) continue;
|
||||
const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue;
|
||||
if (seen.has(nameRaw.toLowerCase())) continue;
|
||||
seen.add(nameRaw.toLowerCase());
|
||||
rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });
|
||||
addRule(nameRaw, stripBold(mm[2]).trim());
|
||||
}
|
||||
|
||||
return rules;
|
||||
|
||||
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
}),
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
||||
'Pragma': 'no-cache',
|
||||
});
|
||||
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
}
|
||||
if (p === '/detect.js' || p === '/') {
|
||||
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
|
||||
res.end(detectScript);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ allowed-tools:
|
||||
- Bash(node .claude/skills/impeccable/scripts/*)
|
||||
---
|
||||
|
||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as a award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||
|
||||
Core principles:
|
||||
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
|
||||
@@ -20,7 +20,7 @@ Core principles:
|
||||
## Setup
|
||||
|
||||
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .claude/skills/impeccable/scripts/...` command in this skill and its references, and `.claude/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
|
||||
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
|
||||
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
|
||||
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
|
||||
|
||||
## How to design
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
### Purpose
|
||||
|
||||
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
|
||||
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
|
||||
|
||||
### Hard Invariants
|
||||
|
||||
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
|
||||
|
||||
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
|
||||
|
||||
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
|
||||
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
|
||||
|
||||
Structure your feedback as a design director would:
|
||||
|
||||
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
|
||||
node .claude/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
|
||||
```
|
||||
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
|
||||
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
|
||||
|
||||
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ The first argument is the action. Defaults to `status`.
|
||||
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
|
||||
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
|
||||
| `ignore-value <id> "*" --file <glob> [--file <glob>...]` | Turn one rule off in matching files only, leaving it active everywhere else. Repeat `--file`, or use `--file=<glob>` / `--files=<glob>`. A bare `"*"` with no `--file` is refused: use `ignore-rule <id>` if you really mean project-wide. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue, and remove the hook's entries from every provider manifest `on` installs, the committed Copilot file included (a team-shared `settings.json` that `on` never writes is never touched). |
|
||||
|
||||
## Flow
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
|
||||
4. Run `node .claude/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
|
||||
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
|
||||
|
||||
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
|
||||
@@ -70,16 +70,20 @@ Your measured rendition prior: warm, bookish, family, and child-facing subjects
|
||||
|
||||
## 5. Record the decision
|
||||
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
Before code, record the chosen direction as a development-only contract under `## Direction contract` in the relevant surface brief. A direction contract is durable route or artifact strategy, so create or update the brief even when no other surface strategy needs persistence. Keep the contract to six short blocks and 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The surface brief is the reminder later agents reload across edits and sessions: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
|
||||
Never copy the direction contract into implementation source or any browser-delivered artifact. This includes HTML or framework comments, hidden DOM, `<template>` elements, `data-*` attributes, rendered JSX or TSX output, serialized props or state, React Server Component payloads, client bundles, metadata or JSON-LD, accessibility-only text, and files served beside the artifact. A compiler or optimizer removing development metadata is not a safety boundary. Reviewers and documenters receive the contract from the surface brief.
|
||||
|
||||
On a new or replacement world, DESIGN.md is written at finish, from the built world, by the shipped documenter (section 7); a rulebook written before the build gets defended against reality instead of describing it, and hands the design-system detector an unstable target. A new world shipped with no DESIGN.md is still an incomplete run. An ordinary extension does not rewrite DESIGN.md.
|
||||
|
||||
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
|
||||
Read the existing surface brief before updating it:
|
||||
|
||||
`node .claude/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
|
||||
|
||||
`node .claude/skills/impeccable/scripts/surface-brief.mjs write <primary-target> <body-file> [related-target ...]`
|
||||
|
||||
After writing, read the brief once more and verify that all six contract blocks and the seed key are present before building.
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
On a comp-led build, whenever any image generation is available (a harness-native tool or the API fallback context.mjs reports), the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options put before the user for approval, the chosen card's decision comp plus two variations. This step is proven to produce the most compositional and ambitious work. On a code-led build the comp round is skipped by contract, never by drift: the ambition it would have carried lives in the direction contract's FIRST VIEWPORT block and named signature interaction, and the finish reviewer audits those promises in behavior.
|
||||
|
||||
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
|
||||
If a prior critique exists, use it as one input:
|
||||
|
||||
```bash
|
||||
node .claude/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
|
||||
node .claude/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
|
||||
```
|
||||
|
||||
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
|
||||
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
|
||||
|
||||
## 3. Triage
|
||||
|
||||
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
|
||||
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
|
||||
|
||||
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
|
||||
|
||||
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
|
||||
|
||||
```bash
|
||||
node .claude/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
|
||||
```
|
||||
|
||||
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
|
||||
|
||||
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
|
||||
|
||||
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
|
||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
approvedPoolRevision,
|
||||
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
|
||||
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
export function sameMainModulePath(left, right, platform = process.platform) {
|
||||
if (platform !== 'win32') return left === right;
|
||||
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
|
||||
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
|
||||
// invoked path. Compare real paths so a linked skill still runs its CLI,
|
||||
// normalizing the drive-letter casing that Windows junctions can change.
|
||||
return sameMainModulePath(
|
||||
realpathSync(process.argv[1]),
|
||||
realpathSync(fileURLToPath(import.meta.url))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const args = process.argv.slice(2);
|
||||
const fromIdx = args.indexOf('--from');
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* CLI entry points (called from skill instructions):
|
||||
* node critique-storage.mjs slug <resolved-target>
|
||||
* node critique-storage.mjs write <slug> <snapshot-body-file>
|
||||
* node critique-storage.mjs latest <slug>
|
||||
* node critique-storage.mjs latest <slug> [--json]
|
||||
* node critique-storage.mjs trend <slug> [limit]
|
||||
* node critique-storage.mjs close <resolved-target> <snapshot-file>
|
||||
*
|
||||
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
|
||||
* markdown file; the model reads it directly with its file-read tool. This
|
||||
@@ -27,6 +28,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
import { slugFromTarget } from './lib/target-slug.mjs';
|
||||
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
|
||||
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an exact content fingerprint for a local file target. URLs and
|
||||
* non-files return null because their content is not available here.
|
||||
*
|
||||
* The fingerprint deliberately describes bytes, not Git state or mtimes:
|
||||
* critique often assesses an uncommitted file, and a later polish run should
|
||||
* inherit that backlog when the bytes are unchanged regardless of staging.
|
||||
*/
|
||||
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
|
||||
if (!target || /^https?:\/\//i.test(target)) return null;
|
||||
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
|
||||
}
|
||||
|
||||
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
|
||||
if (!target || typeof target !== 'string') return null;
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
try {
|
||||
const url = new URL(target);
|
||||
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
||||
return `url:${url.origin}${pathname}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const filePath = resolveLocalTargetPath(target, { cwd });
|
||||
return filePath ? `file:${filePath}` : null;
|
||||
}
|
||||
|
||||
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
|
||||
const filePath = resolveLocalTargetPath(target, { cwd });
|
||||
if (!filePath) return null;
|
||||
try {
|
||||
if (!fs.statSync(filePath).isFile()) return null;
|
||||
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
|
||||
* keys read back by readTrend(). `body` is the human-readable critique
|
||||
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
|
||||
const dir = getCritiqueDir(cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const timestamp = nowFilenameStamp(now);
|
||||
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
|
||||
// Spread `meta` first so internally computed `timestamp` and `slug`
|
||||
// always win. Otherwise a caller-supplied meta blob (parsed from the
|
||||
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
|
||||
// filename in disagreement with its frontmatter and corrupting trends.
|
||||
const front = serializeFrontmatter({ ...meta, timestamp, slug });
|
||||
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
|
||||
return filePath;
|
||||
const contents = `${front}\n${body.trim()}\n`;
|
||||
|
||||
// A second critique can finish in the same UTC second. Use exclusive
|
||||
// creation and a fixed-width suffix so concurrent writers cannot replace
|
||||
// history and lexical ordering still keeps collision entries newest.
|
||||
for (let collision = 0; collision <= 9999; collision += 1) {
|
||||
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
|
||||
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
|
||||
try {
|
||||
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
|
||||
}
|
||||
|
||||
function serializeFrontmatter(obj) {
|
||||
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
|
||||
try { value = JSON.parse(value); } catch { /* leave as-is */ }
|
||||
} else if (/^-?\d+$/.test(value)) {
|
||||
value = Number(value);
|
||||
} else if (value === 'true' || value === 'false') {
|
||||
value = value === 'true';
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
|
||||
/**
|
||||
* Return snapshot files matching `suffix`, sorted oldest → newest.
|
||||
*/
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
|
||||
|
||||
function listSnapshots(suffix, cwd) {
|
||||
const dir = getCritiqueDir(cwd);
|
||||
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
|
||||
.map((f) => path.join(dir, f));
|
||||
}
|
||||
|
||||
function readLatestSnapshotMatching(suffix, cwd) {
|
||||
const filePath = listSnapshots(suffix, cwd).at(-1);
|
||||
function readSnapshot(filePath) {
|
||||
if (!filePath) return null;
|
||||
const body = fs.readFileSync(filePath, 'utf-8');
|
||||
return { path: filePath, body, meta: parseFrontmatter(body) };
|
||||
}
|
||||
|
||||
function snapshotTargetIdentity(snapshot) {
|
||||
const targetPath = snapshot?.meta.target_path;
|
||||
return snapshot?.meta.target_identity
|
||||
|| (targetPath ? `file:${targetPath}` : null);
|
||||
}
|
||||
|
||||
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
|
||||
}
|
||||
|
||||
function readNewestSnapshotForIdentity(
|
||||
slug,
|
||||
targetIdentity,
|
||||
{ cwd = process.cwd() } = {},
|
||||
) {
|
||||
const matches = listSnapshots(`__${slug}.md`, cwd)
|
||||
.map(readSnapshot)
|
||||
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
|
||||
return matches.at(-1) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
||||
* to find its fix backlog when the slug matches.
|
||||
*/
|
||||
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
|
||||
const latest = readNewestSnapshot(slug, { cwd });
|
||||
return latest?.meta.closed === true ? null : latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one exact snapshot closed without deleting the score history consumed
|
||||
* by `trend`. Exact identity matters: a newer critique may land after polish
|
||||
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
|
||||
* may be the absolute path returned by readLatestSnapshot() or the basename
|
||||
* emitted by `latest --json`. Returns the path marked closed, or null.
|
||||
*/
|
||||
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
|
||||
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
|
||||
const dir = path.resolve(getCritiqueDir(cwd));
|
||||
const snapshotPath = path.isAbsolute(snapshotFile)
|
||||
? path.resolve(snapshotFile)
|
||||
: path.resolve(dir, snapshotFile);
|
||||
const filename = path.basename(snapshotPath);
|
||||
if (
|
||||
path.dirname(snapshotPath) !== dir
|
||||
|| !SNAPSHOT_FILENAME.test(filename)
|
||||
) return null;
|
||||
|
||||
let snapshot;
|
||||
try {
|
||||
if (!fs.lstatSync(snapshotPath).isFile()) return null;
|
||||
snapshot = readSnapshot(snapshotPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!snapshot || snapshot.meta.closed === true) return null;
|
||||
const closedBody = snapshot.body.replace(
|
||||
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
|
||||
'$1\nclosed: true$2',
|
||||
);
|
||||
if (closedBody === snapshot.body) {
|
||||
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
|
||||
}
|
||||
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
|
||||
return snapshot.path;
|
||||
}
|
||||
|
||||
/** Return the most recent snapshot across all targets, or null. */
|
||||
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching('.md', cwd);
|
||||
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
|
||||
const identifiedSlugs = new Set(
|
||||
snapshots
|
||||
.filter((snapshot) => snapshotTargetIdentity(snapshot))
|
||||
.map((snapshot) => snapshot.meta.slug),
|
||||
);
|
||||
const latestByTarget = new Map();
|
||||
for (const snapshot of snapshots) {
|
||||
if (!snapshot?.meta.slug) continue;
|
||||
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
|
||||
// one. Keep each known identity's latest open/closed state independent so
|
||||
// closing one target cannot hide another target's live backlog. Once a
|
||||
// slug has any identity-aware snapshot, its older legacy records are no
|
||||
// longer independently routable and must not resurface as zombie work.
|
||||
const targetIdentity = snapshotTargetIdentity(snapshot);
|
||||
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
|
||||
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
|
||||
latestByTarget.set(streamKey, snapshot);
|
||||
}
|
||||
return [...latestByTarget.values()]
|
||||
.filter((snapshot) => snapshot.meta.closed !== true)
|
||||
.sort((a, b) => a.path.localeCompare(b.path))
|
||||
.at(-1) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
||||
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
|
||||
// callers never have to run the slug step separately. Anything containing a
|
||||
// path or URL marker is resolved through slugFromTarget.
|
||||
function isReadySlug(value) {
|
||||
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
|
||||
}
|
||||
|
||||
function coerceSlug(value) {
|
||||
if (!value) return null;
|
||||
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
|
||||
if (isReadySlug(value)) return value;
|
||||
return slugFromTarget(value);
|
||||
}
|
||||
|
||||
@@ -181,14 +322,124 @@ function main(argv) {
|
||||
if (metaArg) {
|
||||
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
|
||||
}
|
||||
// The helper, not caller-provided metadata, owns the target fingerprint.
|
||||
// This makes the snapshot describe the exact file bytes critique saw.
|
||||
delete meta.target_fingerprint;
|
||||
delete meta.target_path;
|
||||
delete meta.target_identity;
|
||||
const targetIdentity = resolveTargetIdentity(slugArg);
|
||||
if (targetIdentity) meta.target_identity = targetIdentity;
|
||||
const targetFingerprint = fingerprintTarget(slugArg);
|
||||
if (targetFingerprint) {
|
||||
meta.target_fingerprint = targetFingerprint;
|
||||
meta.target_path = resolveLocalTargetPath(slugArg);
|
||||
}
|
||||
const out = writeSnapshot({ slug, meta, body: raw });
|
||||
process.stdout.write(`${out}\n`);
|
||||
return;
|
||||
}
|
||||
case 'latest': {
|
||||
const latest = readLatestSnapshot(coerceSlug(args[0]));
|
||||
if (!latest) { process.exit(2); }
|
||||
process.stdout.write(latest.body);
|
||||
const target = args[0];
|
||||
const format = args[1];
|
||||
const slug = coerceSlug(target);
|
||||
if (!slug || (format && format !== '--json')) {
|
||||
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const targetFingerprint = fingerprintTarget(target);
|
||||
const targetPath = resolveLocalTargetPath(target);
|
||||
const targetIdentity = resolveTargetIdentity(target);
|
||||
const readySlug = isReadySlug(target);
|
||||
const newestForSlug = readNewestSnapshot(slug);
|
||||
if (!newestForSlug) { process.exit(2); }
|
||||
|
||||
// Concrete targets select the newest snapshot for their exact identity,
|
||||
// not merely the newest filename for a lossy slug. This keeps distinct
|
||||
// targets such as foo/bar and foo-bar from hiding each other's backlog.
|
||||
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
|
||||
let latest = exactSnapshot;
|
||||
if (!latest && !readySlug) {
|
||||
// Legacy snapshots have no identity. Preserve their old explicit
|
||||
// path/URL behavior only when no known target identity was selected.
|
||||
latest = readNewestSnapshotForIdentity(slug, null);
|
||||
}
|
||||
if (!latest) latest = newestForSlug;
|
||||
if (latest.meta.closed === true) { process.exit(2); }
|
||||
|
||||
const recordedTargetPath = latest.meta.target_path;
|
||||
const recordedTargetIdentity = snapshotTargetIdentity(latest);
|
||||
const matchingIdentity = recordedTargetIdentity === targetIdentity;
|
||||
|
||||
// Bare slugs remain a supported lookup mode, including for URL
|
||||
// snapshots. But when a same-named local file exists, the request is
|
||||
// ambiguous unless that exact file owns the snapshot identity.
|
||||
if (readySlug && !recordedTargetIdentity) {
|
||||
process.stderr.write(
|
||||
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
|
||||
process.stderr.write(
|
||||
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const concreteTarget = !readySlug || matchingIdentity;
|
||||
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
|
||||
process.exit(2);
|
||||
}
|
||||
const concreteLocalTarget = concreteTarget && targetPath;
|
||||
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
|
||||
closeSnapshot(latest.path);
|
||||
process.exit(2);
|
||||
}
|
||||
if (format === '--json') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
snapshot_file: path.basename(latest.path),
|
||||
body: latest.body,
|
||||
}, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(latest.body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'close': {
|
||||
const [slugArg, snapshotFile, ...extra] = args;
|
||||
const slug = coerceSlug(slugArg);
|
||||
if (!slug || !snapshotFile || extra.length > 0) {
|
||||
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
if (
|
||||
path.basename(snapshotFile) !== snapshotFile
|
||||
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|
||||
|| !snapshotFile.endsWith(`__${slug}.md`)
|
||||
) process.exit(2);
|
||||
|
||||
// A slug and filename are not enough to prove ownership because two
|
||||
// distinct targets can normalize to the same slug. Modern snapshots
|
||||
// carry a canonical identity, so require the supplied resolved target
|
||||
// to match it before allowing the exact snapshot to be closed. Legacy
|
||||
// snapshots without identity retain their historical close behavior.
|
||||
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
|
||||
let snapshot;
|
||||
try {
|
||||
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
|
||||
snapshot = readSnapshot(snapshotPath);
|
||||
} catch {
|
||||
process.exit(2);
|
||||
}
|
||||
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
|
||||
if (
|
||||
recordedTargetIdentity
|
||||
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
|
||||
) process.exit(2);
|
||||
|
||||
const closed = closeSnapshot(snapshotFile);
|
||||
if (!closed) { process.exit(2); }
|
||||
process.stdout.write(`${closed}\n`);
|
||||
return;
|
||||
}
|
||||
case 'trend': {
|
||||
@@ -197,7 +448,7 @@ function main(argv) {
|
||||
return;
|
||||
}
|
||||
default:
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
|
||||
return String(value || '')
|
||||
.replace(/\s*\/\s*/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(token => token.trim())
|
||||
// var() fallbacks leave the closing parenthesis on the final token. Strip
|
||||
// it before length resolution so `8px)` is not treated as unitless 8rem.
|
||||
.map(token => token.trim().replace(/\)+$/, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,13 +70,27 @@ function isBrandFontOnOwnDomain(font) {
|
||||
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||
}
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
// Overused-font primary selection skips only CSS generics so a system stack
|
||||
// keeps the system face as primary; GENERIC_FONTS still includes platform
|
||||
// faces for design-system/serif resolution.
|
||||
const CSS_GENERIC_FONTS = new Set([
|
||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
...CSS_GENERIC_FONTS,
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
]);
|
||||
|
||||
function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
|
||||
return String(fontFamily || '')
|
||||
.split(',')
|
||||
.map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
|
||||
.find(f => f && !skip.has(f)) || null;
|
||||
}
|
||||
|
||||
// WCAG large text thresholds are defined in points: 18pt normal text and
|
||||
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
@@ -1591,7 +1605,7 @@ function checkIconTile(opts) {
|
||||
function resolveSerif(fontFamily) {
|
||||
if (!fontFamily) return { primary: null, isSerif: false };
|
||||
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
|
||||
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
|
||||
if (!primary) return { primary: null, isSerif: false };
|
||||
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
|
||||
if (tokens.includes('serif')) return { primary, isSerif: true };
|
||||
@@ -5190,8 +5204,7 @@ function checkTypography() {
|
||||
const style = getComputedStyle(el);
|
||||
const ff = style.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (!primary) continue;
|
||||
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
||||
totalTextElements++;
|
||||
@@ -5436,8 +5449,7 @@ function checkPageTypography(doc, win) {
|
||||
if (rule.type !== 1) continue;
|
||||
const ff = rule.style?.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
@@ -5456,11 +5468,10 @@ function checkPageTypography(doc, win) {
|
||||
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
let fm;
|
||||
while ((fm = ffRe.exec(html)) !== null) {
|
||||
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
||||
if (f && !GENERIC_FONTS.has(f)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
const primary = primaryFontFace(fm[1]);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { OVERUSED_FONTS, primaryFontFace } from '../../shared/constants.mjs';
|
||||
import {
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
@@ -51,9 +51,7 @@ function checkStaticPageTypography(document, window) {
|
||||
for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) {
|
||||
const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasText) continue;
|
||||
const ff = window.getComputedStyle(el).fontFamily || '';
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(window.getComputedStyle(el).fontFamily);
|
||||
if (!primary) continue;
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
isBrandFontOnOwnDomain,
|
||||
primaryFontFace,
|
||||
} from '../shared/constants.mjs';
|
||||
import {
|
||||
CSS_NAMED_COLORS,
|
||||
@@ -331,7 +332,7 @@ function checkIconTile(opts) {
|
||||
function resolveSerif(fontFamily) {
|
||||
if (!fontFamily) return { primary: null, isSerif: false };
|
||||
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
|
||||
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
|
||||
if (!primary) return { primary: null, isSerif: false };
|
||||
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
|
||||
if (tokens.includes('serif')) return { primary, isSerif: true };
|
||||
@@ -3930,8 +3931,7 @@ function checkTypography() {
|
||||
const style = getComputedStyle(el);
|
||||
const ff = style.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (!primary) continue;
|
||||
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
||||
totalTextElements++;
|
||||
@@ -4176,8 +4176,7 @@ function checkPageTypography(doc, win) {
|
||||
if (rule.type !== 1) continue;
|
||||
const ff = rule.style?.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
@@ -4196,11 +4195,10 @@ function checkPageTypography(doc, win) {
|
||||
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
let fm;
|
||||
while ((fm = ffRe.exec(html)) !== null) {
|
||||
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
||||
if (f && !GENERIC_FONTS.has(f)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
const primary = primaryFontFace(fm[1]);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,13 +56,27 @@ function isBrandFontOnOwnDomain(font) {
|
||||
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||
}
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
// Overused-font primary selection skips only CSS generics so a system stack
|
||||
// keeps the system face as primary; GENERIC_FONTS still includes platform
|
||||
// faces for design-system/serif resolution.
|
||||
const CSS_GENERIC_FONTS = new Set([
|
||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
...CSS_GENERIC_FONTS,
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
]);
|
||||
|
||||
function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
|
||||
return String(fontFamily || '')
|
||||
.split(',')
|
||||
.map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
|
||||
.find(f => f && !skip.has(f)) || null;
|
||||
}
|
||||
|
||||
// WCAG large text thresholds are defined in points: 18pt normal text and
|
||||
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
@@ -104,6 +118,7 @@ export {
|
||||
BRAND_FONT_DOMAINS,
|
||||
isBrandFontOnOwnDomain,
|
||||
GENERIC_FONTS,
|
||||
primaryFontFace,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
|
||||
@@ -21,22 +21,24 @@ import zlib from 'node:zlib';
|
||||
const KEYWORD = 'impeccable:prompt';
|
||||
const args = process.argv.slice(2);
|
||||
const file = args.find(a => !a.startsWith('--'));
|
||||
const readMode = args.includes('--read');
|
||||
const scanMode = args.includes('--scan');
|
||||
const argOf = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : null; };
|
||||
|
||||
function promptOf(imagePath) {
|
||||
const b = fs.readFileSync(imagePath);
|
||||
let prompt = null;
|
||||
if (b.length > 8 && b.readUInt32BE(0) === 0x89504e47) prompt = readPngText(b);
|
||||
else if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8) prompt = readJpegCom(b);
|
||||
function imageType(buffer) {
|
||||
if (buffer.length > 8 && buffer.readUInt32BE(0) === 0x89504e47) return 'png';
|
||||
if (buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8) return 'jpeg';
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPrompt(imagePath, buffer = fs.readFileSync(imagePath)) {
|
||||
const type = imageType(buffer);
|
||||
let prompt = type === 'png' ? parsePng(buffer).prompt : type === 'jpeg' ? readJpegCom(buffer) : null;
|
||||
if (prompt == null && fs.existsSync(`${imagePath}.json`)) {
|
||||
try { prompt = JSON.parse(fs.readFileSync(`${imagePath}.json`, 'utf8')).prompt ?? null; } catch { /* stays null */ }
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
if (scanMode) {
|
||||
if (args.includes('--scan')) {
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
if (targets.length === 0) { console.error('embed-prompt: --scan needs at least one directory'); process.exit(1); }
|
||||
const RASTER = /\.(png|jpe?g|webp)$/i;
|
||||
@@ -59,7 +61,7 @@ if (scanMode) {
|
||||
}
|
||||
let missing = 0;
|
||||
for (const raster of rasters) {
|
||||
if (promptOf(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
|
||||
if (readPrompt(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
|
||||
}
|
||||
console.log(`SCAN: ${rasters.length} raster${rasters.length === 1 ? '' : 's'}, ${missing} missing`);
|
||||
process.exit(missing > 0 ? 3 : 0);
|
||||
@@ -68,8 +70,7 @@ if (scanMode) {
|
||||
if (!file || !fs.existsSync(file)) { console.error('embed-prompt: image file required'); process.exit(1); }
|
||||
|
||||
const buf = fs.readFileSync(file);
|
||||
const isPng = buf.length > 8 && buf.readUInt32BE(0) === 0x89504e47;
|
||||
const isJpeg = buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8;
|
||||
const type = imageType(buf);
|
||||
|
||||
const crcTable = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
@@ -87,22 +88,26 @@ function pngChunk(type, data) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function readPngText(b) {
|
||||
let off = 8;
|
||||
while (off + 12 <= b.length) {
|
||||
const len = b.readUInt32BE(off);
|
||||
const type = b.toString('ascii', off + 4, off + 8);
|
||||
if (type === 'tEXt' || type === 'zTXt') {
|
||||
const data = b.subarray(off + 8, off + 8 + len);
|
||||
const nul = data.indexOf(0);
|
||||
if (nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD) {
|
||||
if (type === 'tEXt') return data.toString('utf8', nul + 1);
|
||||
return zlib.inflateSync(data.subarray(nul + 2)).toString('utf8');
|
||||
}
|
||||
function parsePng(buffer) {
|
||||
const chunks = [];
|
||||
let prompt = null;
|
||||
let offset = 8;
|
||||
while (offset + 12 <= buffer.length) {
|
||||
const length = buffer.readUInt32BE(offset);
|
||||
const type = buffer.toString('ascii', offset + 4, offset + 8);
|
||||
const data = buffer.subarray(offset + 8, offset + 8 + length);
|
||||
const nul = data.indexOf(0);
|
||||
const promptChunk = (type === 'tEXt' || type === 'zTXt')
|
||||
&& nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
|
||||
if (prompt == null && promptChunk) {
|
||||
prompt = type === 'tEXt'
|
||||
? data.toString('utf8', nul + 1)
|
||||
: zlib.inflateSync(data.subarray(nul + 2)).toString('utf8');
|
||||
}
|
||||
off += 12 + len;
|
||||
chunks.push({ offset, type, promptChunk, bytes: buffer.subarray(offset, offset + 12 + length) });
|
||||
offset += 12 + length;
|
||||
}
|
||||
return null;
|
||||
return { chunks, prompt };
|
||||
}
|
||||
|
||||
function readJpegCom(b) {
|
||||
@@ -121,48 +126,34 @@ function readJpegCom(b) {
|
||||
}
|
||||
|
||||
const sidecar = `${file}.json`;
|
||||
if (readMode) {
|
||||
let prompt = null;
|
||||
if (isPng) prompt = readPngText(buf);
|
||||
else if (isJpeg) prompt = readJpegCom(buf);
|
||||
if (prompt == null && fs.existsSync(sidecar)) {
|
||||
try { prompt = JSON.parse(fs.readFileSync(sidecar, 'utf8')).prompt ?? null; } catch { /* fall through */ }
|
||||
}
|
||||
if (args.includes('--read')) {
|
||||
const prompt = readPrompt(file, buf);
|
||||
if (prompt == null) { console.error('embed-prompt: no embedded prompt found'); process.exit(2); }
|
||||
console.log(prompt);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const prompt = argOf('--prompt') ?? (argOf('--prompt-file') ? fs.readFileSync(argOf('--prompt-file'), 'utf8') : null);
|
||||
const promptFile = argOf('--prompt-file');
|
||||
const prompt = argOf('--prompt') ?? (promptFile ? fs.readFileSync(promptFile, 'utf8') : null);
|
||||
if (!prompt) { console.error('embed-prompt: --prompt or --prompt-file required'); process.exit(1); }
|
||||
|
||||
if (isPng) {
|
||||
if (type === 'png') {
|
||||
// Insert (or replace) our tEXt chunk immediately before IEND.
|
||||
const iend = buf.indexOf(Buffer.from('IEND', 'ascii')) - 4;
|
||||
const { chunks, prompt: existingPrompt } = parsePng(buf);
|
||||
const iend = chunks.find((chunk) => chunk.type === 'IEND')?.offset ?? -1;
|
||||
if (iend < 8) { console.error('embed-prompt: malformed PNG'); process.exit(1); }
|
||||
// Drop any existing chunk with our keyword to keep embedding idempotent.
|
||||
let body = buf.subarray(8, iend);
|
||||
const existing = readPngText(buf);
|
||||
if (existing != null) {
|
||||
const parts = [];
|
||||
let off = 8;
|
||||
while (off + 12 <= buf.length && off < iend + 12) {
|
||||
const len = buf.readUInt32BE(off);
|
||||
const type = buf.toString('ascii', off + 4, off + 8);
|
||||
const chunk = buf.subarray(off, off + 12 + len);
|
||||
const data = buf.subarray(off + 8, off + 8 + len);
|
||||
const nul = data.indexOf(0);
|
||||
const ours = (type === 'tEXt' || type === 'zTXt') && nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
|
||||
if (!ours && type !== 'IEND') parts.push(chunk);
|
||||
off += 12 + len;
|
||||
}
|
||||
body = Buffer.concat(parts).subarray(8 * 0); // parts exclude signature
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), pngChunk('IEND', Buffer.alloc(0))]));
|
||||
} else {
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, iend), pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), buf.subarray(iend)]));
|
||||
}
|
||||
const replacing = existingPrompt != null;
|
||||
const body = replacing
|
||||
? Buffer.concat(chunks
|
||||
.filter((chunk) => chunk.offset < iend && !chunk.promptChunk)
|
||||
.map((chunk) => chunk.bytes))
|
||||
: buf.subarray(8, iend);
|
||||
const promptChunk = pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')]));
|
||||
const end = replacing ? pngChunk('IEND', Buffer.alloc(0)) : buf.subarray(iend);
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, promptChunk, end]));
|
||||
console.log(`EMBEDDED: ${file} (png tEXt, ${prompt.length} chars)`);
|
||||
} else if (isJpeg) {
|
||||
} else if (type === 'jpeg') {
|
||||
const seg = Buffer.from(`${KEYWORD}\0${prompt}`, 'utf8');
|
||||
if (seg.length + 2 > 0xffff) { console.error('embed-prompt: prompt too long for a JPEG segment'); process.exit(1); }
|
||||
const com = Buffer.alloc(4 + seg.length);
|
||||
|
||||
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
|
||||
// The prompt travels with the asset: embedded in the file itself (EXIF-class
|
||||
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
|
||||
// plus a sidecar for anything that indexes rather than opens the image.
|
||||
let embedded = false;
|
||||
try {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
embedded = !result.error && result.status === 0;
|
||||
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
|
||||
} catch { /* embedding is best-effort */ }
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
|
||||
if (plateCtx && plateCtx.chroma) {
|
||||
const frac = await keyChroma(out, plateCtx.chroma);
|
||||
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
|
||||
|
||||
@@ -770,9 +770,22 @@ function reset(cwd) {
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return removed.length
|
||||
? `Reset design hook config and cache (removed: ${removed.join(', ')}).`
|
||||
: 'No hook config or cache to remove. Already at defaults.';
|
||||
// `on` writes three things: config, consent, and hook entries in the
|
||||
// provider manifests. Reset must undo all three (issue #512): a leftover
|
||||
// manifest entry kept invoking the hook after the config that said "off"
|
||||
// was deleted. Local destRel only, since `on` never writes the team-shared
|
||||
// sharedDestRel. No skill-folder gate: a reset mid-uninstall (skill files
|
||||
// gone, manifest still wired) is the case that most needs the prune.
|
||||
const pruned = [];
|
||||
for (const target of HOOK_MANIFEST_TARGETS) {
|
||||
try {
|
||||
if (pruneImpeccableHookFromManifest(path.join(cwd, target.destRel))) pruned.push(target.provider);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const parts = [];
|
||||
if (removed.length) parts.push(`Reset design hook config and cache (removed: ${removed.join(', ')}).`);
|
||||
if (pruned.length) parts.push(`Removed hook entries from: ${pruned.join(', ')}.`);
|
||||
return parts.length ? parts.join(' ') : 'No hook config or cache to remove. Already at defaults.';
|
||||
}
|
||||
|
||||
function main() {
|
||||
|
||||
@@ -329,47 +329,37 @@ function stripBold(s) {
|
||||
function extractNamedRules(lines) {
|
||||
const rules = [];
|
||||
const seen = new Set();
|
||||
const addRule = (name, body, { allowDuplicate = false } = {}) => {
|
||||
const key = name.toLowerCase();
|
||||
if (!allowDuplicate && seen.has(key)) return;
|
||||
seen.add(key);
|
||||
rules.push({ name, body });
|
||||
};
|
||||
|
||||
// Style A (Impeccable): "**The X Rule.** body body body" — can span lines.
|
||||
const joined = lines.join('\n');
|
||||
const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g;
|
||||
const inlineMatches = [];
|
||||
let m;
|
||||
while ((m = inlineStart.exec(joined)) !== null) {
|
||||
inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex });
|
||||
}
|
||||
const inlineMatches = [...joined.matchAll(/\*\*(The [^*]+?Rule)\.\*\*/g)];
|
||||
for (let i = 0; i < inlineMatches.length; i++) {
|
||||
const mm = inlineMatches[i];
|
||||
const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;
|
||||
const match = inlineMatches[i];
|
||||
const bodyEnd = inlineMatches[i + 1]?.index ?? joined.length;
|
||||
const body = joined
|
||||
.slice(mm.end, bodyEnd)
|
||||
.slice(match.index + match[0].length, bodyEnd)
|
||||
.replace(/\n##[^\n]*$/s, '')
|
||||
.replace(/\n###[^\n]*$/s, '')
|
||||
.trim();
|
||||
const name = stripBold(mm.name).trim();
|
||||
seen.add(name.toLowerCase());
|
||||
rules.push({ name, body: stripBold(body) });
|
||||
// Preserve the inline format's historical behavior: repeated inline rules
|
||||
// remain visible, while the later heading and bullet formats dedupe.
|
||||
addRule(stripBold(match[1]).trim(), stripBold(body), { allowDuplicate: true });
|
||||
}
|
||||
|
||||
// Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the
|
||||
// bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const h3 = lines[i].match(/^###\s+(.+?)\s*$/);
|
||||
if (!h3) continue;
|
||||
const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim();
|
||||
for (const subsection of splitSubsections(lines).slice(1)) {
|
||||
const headerName = stripBold(subsection.name).replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;
|
||||
if (seen.has(headerName.toLowerCase())) continue;
|
||||
|
||||
const bodyLines = [];
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
if (/^##\s|^###\s/.test(lines[j])) break;
|
||||
bodyLines.push(lines[j]);
|
||||
}
|
||||
const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim();
|
||||
if (body) {
|
||||
seen.add(headerName.toLowerCase());
|
||||
rules.push({ name: headerName, body });
|
||||
}
|
||||
const body = stripBold(subsection.lines.join('\n').replace(/\n+/g, ' ')).trim();
|
||||
if (body) addRule(headerName, body);
|
||||
}
|
||||
|
||||
// Style C (Stitch bullet form): "* **The Layering Principle:** body"
|
||||
@@ -379,9 +369,7 @@ function extractNamedRules(lines) {
|
||||
if (!mm) continue;
|
||||
const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue;
|
||||
if (seen.has(nameRaw.toLowerCase())) continue;
|
||||
seen.add(nameRaw.toLowerCase());
|
||||
rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });
|
||||
addRule(nameRaw, stripBold(mm[2]).trim());
|
||||
}
|
||||
|
||||
return rules;
|
||||
|
||||
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
}),
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
||||
'Pragma': 'no-cache',
|
||||
});
|
||||
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
}
|
||||
if (p === '/detect.js' || p === '/') {
|
||||
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
|
||||
res.end(detectScript);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ version: 4.1.2
|
||||
license: Apache 2.0
|
||||
---
|
||||
|
||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as a award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||
|
||||
Core principles:
|
||||
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
|
||||
@@ -15,7 +15,7 @@ Core principles:
|
||||
## Setup
|
||||
|
||||
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .cursor/skills/impeccable/scripts/...` command in this skill and its references, and `.cursor/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
|
||||
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
|
||||
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
|
||||
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
|
||||
|
||||
## How to design
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
### Purpose
|
||||
|
||||
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
|
||||
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
|
||||
|
||||
### Hard Invariants
|
||||
|
||||
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
|
||||
|
||||
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
|
||||
|
||||
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
|
||||
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
|
||||
|
||||
Structure your feedback as a design director would:
|
||||
|
||||
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
|
||||
node .cursor/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
|
||||
```
|
||||
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
|
||||
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
|
||||
|
||||
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ The first argument is the action. Defaults to `status`.
|
||||
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
|
||||
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
|
||||
| `ignore-value <id> "*" --file <glob> [--file <glob>...]` | Turn one rule off in matching files only, leaving it active everywhere else. Repeat `--file`, or use `--file=<glob>` / `--files=<glob>`. A bare `"*"` with no `--file` is refused: use `ignore-rule <id>` if you really mean project-wide. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue, and remove the hook's entries from every provider manifest `on` installs, the committed Copilot file included (a team-shared `settings.json` that `on` never writes is never touched). |
|
||||
|
||||
## Flow
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
|
||||
4. Run `node .cursor/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
|
||||
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .cursor/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .cursor/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
|
||||
|
||||
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
|
||||
@@ -68,16 +68,20 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su
|
||||
|
||||
## 5. Record the decision
|
||||
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
Before code, record the chosen direction as a development-only contract under `## Direction contract` in the relevant surface brief. A direction contract is durable route or artifact strategy, so create or update the brief even when no other surface strategy needs persistence. Keep the contract to six short blocks and 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The surface brief is the reminder later agents reload across edits and sessions: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
|
||||
Never copy the direction contract into implementation source or any browser-delivered artifact. This includes HTML or framework comments, hidden DOM, `<template>` elements, `data-*` attributes, rendered JSX or TSX output, serialized props or state, React Server Component payloads, client bundles, metadata or JSON-LD, accessibility-only text, and files served beside the artifact. A compiler or optimizer removing development metadata is not a safety boundary. Reviewers and documenters receive the contract from the surface brief.
|
||||
|
||||
On a new or replacement world, DESIGN.md is written at finish, from the built world, by the shipped documenter (section 7); a rulebook written before the build gets defended against reality instead of describing it, and hands the design-system detector an unstable target. A new world shipped with no DESIGN.md is still an incomplete run. An ordinary extension does not rewrite DESIGN.md.
|
||||
|
||||
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
|
||||
Read the existing surface brief before updating it:
|
||||
|
||||
`node .cursor/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
|
||||
|
||||
`node .cursor/skills/impeccable/scripts/surface-brief.mjs write <primary-target> <body-file> [related-target ...]`
|
||||
|
||||
After writing, read the brief once more and verify that all six contract blocks and the seed key are present before building.
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
On a comp-led build, whenever any image generation is available (a harness-native tool or the API fallback context.mjs reports), the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options put before the user for approval, the chosen card's decision comp plus two variations. This step is proven to produce the most compositional and ambitious work. On a code-led build the comp round is skipped by contract, never by drift: the ambition it would have carried lives in the direction contract's FIRST VIEWPORT block and named signature interaction, and the finish reviewer audits those promises in behavior.
|
||||
|
||||
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
|
||||
If a prior critique exists, use it as one input:
|
||||
|
||||
```bash
|
||||
node .cursor/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
|
||||
node .cursor/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
|
||||
```
|
||||
|
||||
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
|
||||
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
|
||||
|
||||
## 3. Triage
|
||||
|
||||
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
|
||||
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
|
||||
|
||||
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
|
||||
|
||||
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
|
||||
|
||||
```bash
|
||||
node .cursor/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
|
||||
```
|
||||
|
||||
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
|
||||
|
||||
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
|
||||
|
||||
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
|
||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
approvedPoolRevision,
|
||||
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
|
||||
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
export function sameMainModulePath(left, right, platform = process.platform) {
|
||||
if (platform !== 'win32') return left === right;
|
||||
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
|
||||
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
|
||||
// invoked path. Compare real paths so a linked skill still runs its CLI,
|
||||
// normalizing the drive-letter casing that Windows junctions can change.
|
||||
return sameMainModulePath(
|
||||
realpathSync(process.argv[1]),
|
||||
realpathSync(fileURLToPath(import.meta.url))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const args = process.argv.slice(2);
|
||||
const fromIdx = args.indexOf('--from');
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* CLI entry points (called from skill instructions):
|
||||
* node critique-storage.mjs slug <resolved-target>
|
||||
* node critique-storage.mjs write <slug> <snapshot-body-file>
|
||||
* node critique-storage.mjs latest <slug>
|
||||
* node critique-storage.mjs latest <slug> [--json]
|
||||
* node critique-storage.mjs trend <slug> [limit]
|
||||
* node critique-storage.mjs close <resolved-target> <snapshot-file>
|
||||
*
|
||||
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
|
||||
* markdown file; the model reads it directly with its file-read tool. This
|
||||
@@ -27,6 +28,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
import { slugFromTarget } from './lib/target-slug.mjs';
|
||||
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
|
||||
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an exact content fingerprint for a local file target. URLs and
|
||||
* non-files return null because their content is not available here.
|
||||
*
|
||||
* The fingerprint deliberately describes bytes, not Git state or mtimes:
|
||||
* critique often assesses an uncommitted file, and a later polish run should
|
||||
* inherit that backlog when the bytes are unchanged regardless of staging.
|
||||
*/
|
||||
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
|
||||
if (!target || /^https?:\/\//i.test(target)) return null;
|
||||
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
|
||||
}
|
||||
|
||||
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
|
||||
if (!target || typeof target !== 'string') return null;
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
try {
|
||||
const url = new URL(target);
|
||||
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
||||
return `url:${url.origin}${pathname}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const filePath = resolveLocalTargetPath(target, { cwd });
|
||||
return filePath ? `file:${filePath}` : null;
|
||||
}
|
||||
|
||||
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
|
||||
const filePath = resolveLocalTargetPath(target, { cwd });
|
||||
if (!filePath) return null;
|
||||
try {
|
||||
if (!fs.statSync(filePath).isFile()) return null;
|
||||
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
|
||||
* keys read back by readTrend(). `body` is the human-readable critique
|
||||
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
|
||||
const dir = getCritiqueDir(cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const timestamp = nowFilenameStamp(now);
|
||||
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
|
||||
// Spread `meta` first so internally computed `timestamp` and `slug`
|
||||
// always win. Otherwise a caller-supplied meta blob (parsed from the
|
||||
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
|
||||
// filename in disagreement with its frontmatter and corrupting trends.
|
||||
const front = serializeFrontmatter({ ...meta, timestamp, slug });
|
||||
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
|
||||
return filePath;
|
||||
const contents = `${front}\n${body.trim()}\n`;
|
||||
|
||||
// A second critique can finish in the same UTC second. Use exclusive
|
||||
// creation and a fixed-width suffix so concurrent writers cannot replace
|
||||
// history and lexical ordering still keeps collision entries newest.
|
||||
for (let collision = 0; collision <= 9999; collision += 1) {
|
||||
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
|
||||
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
|
||||
try {
|
||||
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
|
||||
}
|
||||
|
||||
function serializeFrontmatter(obj) {
|
||||
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
|
||||
try { value = JSON.parse(value); } catch { /* leave as-is */ }
|
||||
} else if (/^-?\d+$/.test(value)) {
|
||||
value = Number(value);
|
||||
} else if (value === 'true' || value === 'false') {
|
||||
value = value === 'true';
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
|
||||
/**
|
||||
* Return snapshot files matching `suffix`, sorted oldest → newest.
|
||||
*/
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
|
||||
|
||||
function listSnapshots(suffix, cwd) {
|
||||
const dir = getCritiqueDir(cwd);
|
||||
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
|
||||
.map((f) => path.join(dir, f));
|
||||
}
|
||||
|
||||
function readLatestSnapshotMatching(suffix, cwd) {
|
||||
const filePath = listSnapshots(suffix, cwd).at(-1);
|
||||
function readSnapshot(filePath) {
|
||||
if (!filePath) return null;
|
||||
const body = fs.readFileSync(filePath, 'utf-8');
|
||||
return { path: filePath, body, meta: parseFrontmatter(body) };
|
||||
}
|
||||
|
||||
function snapshotTargetIdentity(snapshot) {
|
||||
const targetPath = snapshot?.meta.target_path;
|
||||
return snapshot?.meta.target_identity
|
||||
|| (targetPath ? `file:${targetPath}` : null);
|
||||
}
|
||||
|
||||
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
|
||||
}
|
||||
|
||||
function readNewestSnapshotForIdentity(
|
||||
slug,
|
||||
targetIdentity,
|
||||
{ cwd = process.cwd() } = {},
|
||||
) {
|
||||
const matches = listSnapshots(`__${slug}.md`, cwd)
|
||||
.map(readSnapshot)
|
||||
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
|
||||
return matches.at(-1) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
||||
* to find its fix backlog when the slug matches.
|
||||
*/
|
||||
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
|
||||
const latest = readNewestSnapshot(slug, { cwd });
|
||||
return latest?.meta.closed === true ? null : latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one exact snapshot closed without deleting the score history consumed
|
||||
* by `trend`. Exact identity matters: a newer critique may land after polish
|
||||
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
|
||||
* may be the absolute path returned by readLatestSnapshot() or the basename
|
||||
* emitted by `latest --json`. Returns the path marked closed, or null.
|
||||
*/
|
||||
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
|
||||
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
|
||||
const dir = path.resolve(getCritiqueDir(cwd));
|
||||
const snapshotPath = path.isAbsolute(snapshotFile)
|
||||
? path.resolve(snapshotFile)
|
||||
: path.resolve(dir, snapshotFile);
|
||||
const filename = path.basename(snapshotPath);
|
||||
if (
|
||||
path.dirname(snapshotPath) !== dir
|
||||
|| !SNAPSHOT_FILENAME.test(filename)
|
||||
) return null;
|
||||
|
||||
let snapshot;
|
||||
try {
|
||||
if (!fs.lstatSync(snapshotPath).isFile()) return null;
|
||||
snapshot = readSnapshot(snapshotPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!snapshot || snapshot.meta.closed === true) return null;
|
||||
const closedBody = snapshot.body.replace(
|
||||
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
|
||||
'$1\nclosed: true$2',
|
||||
);
|
||||
if (closedBody === snapshot.body) {
|
||||
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
|
||||
}
|
||||
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
|
||||
return snapshot.path;
|
||||
}
|
||||
|
||||
/** Return the most recent snapshot across all targets, or null. */
|
||||
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching('.md', cwd);
|
||||
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
|
||||
const identifiedSlugs = new Set(
|
||||
snapshots
|
||||
.filter((snapshot) => snapshotTargetIdentity(snapshot))
|
||||
.map((snapshot) => snapshot.meta.slug),
|
||||
);
|
||||
const latestByTarget = new Map();
|
||||
for (const snapshot of snapshots) {
|
||||
if (!snapshot?.meta.slug) continue;
|
||||
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
|
||||
// one. Keep each known identity's latest open/closed state independent so
|
||||
// closing one target cannot hide another target's live backlog. Once a
|
||||
// slug has any identity-aware snapshot, its older legacy records are no
|
||||
// longer independently routable and must not resurface as zombie work.
|
||||
const targetIdentity = snapshotTargetIdentity(snapshot);
|
||||
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
|
||||
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
|
||||
latestByTarget.set(streamKey, snapshot);
|
||||
}
|
||||
return [...latestByTarget.values()]
|
||||
.filter((snapshot) => snapshot.meta.closed !== true)
|
||||
.sort((a, b) => a.path.localeCompare(b.path))
|
||||
.at(-1) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
||||
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
|
||||
// callers never have to run the slug step separately. Anything containing a
|
||||
// path or URL marker is resolved through slugFromTarget.
|
||||
function isReadySlug(value) {
|
||||
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
|
||||
}
|
||||
|
||||
function coerceSlug(value) {
|
||||
if (!value) return null;
|
||||
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
|
||||
if (isReadySlug(value)) return value;
|
||||
return slugFromTarget(value);
|
||||
}
|
||||
|
||||
@@ -181,14 +322,124 @@ function main(argv) {
|
||||
if (metaArg) {
|
||||
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
|
||||
}
|
||||
// The helper, not caller-provided metadata, owns the target fingerprint.
|
||||
// This makes the snapshot describe the exact file bytes critique saw.
|
||||
delete meta.target_fingerprint;
|
||||
delete meta.target_path;
|
||||
delete meta.target_identity;
|
||||
const targetIdentity = resolveTargetIdentity(slugArg);
|
||||
if (targetIdentity) meta.target_identity = targetIdentity;
|
||||
const targetFingerprint = fingerprintTarget(slugArg);
|
||||
if (targetFingerprint) {
|
||||
meta.target_fingerprint = targetFingerprint;
|
||||
meta.target_path = resolveLocalTargetPath(slugArg);
|
||||
}
|
||||
const out = writeSnapshot({ slug, meta, body: raw });
|
||||
process.stdout.write(`${out}\n`);
|
||||
return;
|
||||
}
|
||||
case 'latest': {
|
||||
const latest = readLatestSnapshot(coerceSlug(args[0]));
|
||||
if (!latest) { process.exit(2); }
|
||||
process.stdout.write(latest.body);
|
||||
const target = args[0];
|
||||
const format = args[1];
|
||||
const slug = coerceSlug(target);
|
||||
if (!slug || (format && format !== '--json')) {
|
||||
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const targetFingerprint = fingerprintTarget(target);
|
||||
const targetPath = resolveLocalTargetPath(target);
|
||||
const targetIdentity = resolveTargetIdentity(target);
|
||||
const readySlug = isReadySlug(target);
|
||||
const newestForSlug = readNewestSnapshot(slug);
|
||||
if (!newestForSlug) { process.exit(2); }
|
||||
|
||||
// Concrete targets select the newest snapshot for their exact identity,
|
||||
// not merely the newest filename for a lossy slug. This keeps distinct
|
||||
// targets such as foo/bar and foo-bar from hiding each other's backlog.
|
||||
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
|
||||
let latest = exactSnapshot;
|
||||
if (!latest && !readySlug) {
|
||||
// Legacy snapshots have no identity. Preserve their old explicit
|
||||
// path/URL behavior only when no known target identity was selected.
|
||||
latest = readNewestSnapshotForIdentity(slug, null);
|
||||
}
|
||||
if (!latest) latest = newestForSlug;
|
||||
if (latest.meta.closed === true) { process.exit(2); }
|
||||
|
||||
const recordedTargetPath = latest.meta.target_path;
|
||||
const recordedTargetIdentity = snapshotTargetIdentity(latest);
|
||||
const matchingIdentity = recordedTargetIdentity === targetIdentity;
|
||||
|
||||
// Bare slugs remain a supported lookup mode, including for URL
|
||||
// snapshots. But when a same-named local file exists, the request is
|
||||
// ambiguous unless that exact file owns the snapshot identity.
|
||||
if (readySlug && !recordedTargetIdentity) {
|
||||
process.stderr.write(
|
||||
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
|
||||
process.stderr.write(
|
||||
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const concreteTarget = !readySlug || matchingIdentity;
|
||||
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
|
||||
process.exit(2);
|
||||
}
|
||||
const concreteLocalTarget = concreteTarget && targetPath;
|
||||
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
|
||||
closeSnapshot(latest.path);
|
||||
process.exit(2);
|
||||
}
|
||||
if (format === '--json') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
snapshot_file: path.basename(latest.path),
|
||||
body: latest.body,
|
||||
}, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(latest.body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'close': {
|
||||
const [slugArg, snapshotFile, ...extra] = args;
|
||||
const slug = coerceSlug(slugArg);
|
||||
if (!slug || !snapshotFile || extra.length > 0) {
|
||||
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
if (
|
||||
path.basename(snapshotFile) !== snapshotFile
|
||||
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|
||||
|| !snapshotFile.endsWith(`__${slug}.md`)
|
||||
) process.exit(2);
|
||||
|
||||
// A slug and filename are not enough to prove ownership because two
|
||||
// distinct targets can normalize to the same slug. Modern snapshots
|
||||
// carry a canonical identity, so require the supplied resolved target
|
||||
// to match it before allowing the exact snapshot to be closed. Legacy
|
||||
// snapshots without identity retain their historical close behavior.
|
||||
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
|
||||
let snapshot;
|
||||
try {
|
||||
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
|
||||
snapshot = readSnapshot(snapshotPath);
|
||||
} catch {
|
||||
process.exit(2);
|
||||
}
|
||||
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
|
||||
if (
|
||||
recordedTargetIdentity
|
||||
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
|
||||
) process.exit(2);
|
||||
|
||||
const closed = closeSnapshot(snapshotFile);
|
||||
if (!closed) { process.exit(2); }
|
||||
process.stdout.write(`${closed}\n`);
|
||||
return;
|
||||
}
|
||||
case 'trend': {
|
||||
@@ -197,7 +448,7 @@ function main(argv) {
|
||||
return;
|
||||
}
|
||||
default:
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
|
||||
return String(value || '')
|
||||
.replace(/\s*\/\s*/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(token => token.trim())
|
||||
// var() fallbacks leave the closing parenthesis on the final token. Strip
|
||||
// it before length resolution so `8px)` is not treated as unitless 8rem.
|
||||
.map(token => token.trim().replace(/\)+$/, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,13 +70,27 @@ function isBrandFontOnOwnDomain(font) {
|
||||
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||
}
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
// Overused-font primary selection skips only CSS generics so a system stack
|
||||
// keeps the system face as primary; GENERIC_FONTS still includes platform
|
||||
// faces for design-system/serif resolution.
|
||||
const CSS_GENERIC_FONTS = new Set([
|
||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
...CSS_GENERIC_FONTS,
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
]);
|
||||
|
||||
function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
|
||||
return String(fontFamily || '')
|
||||
.split(',')
|
||||
.map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
|
||||
.find(f => f && !skip.has(f)) || null;
|
||||
}
|
||||
|
||||
// WCAG large text thresholds are defined in points: 18pt normal text and
|
||||
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
@@ -1591,7 +1605,7 @@ function checkIconTile(opts) {
|
||||
function resolveSerif(fontFamily) {
|
||||
if (!fontFamily) return { primary: null, isSerif: false };
|
||||
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
|
||||
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
|
||||
if (!primary) return { primary: null, isSerif: false };
|
||||
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
|
||||
if (tokens.includes('serif')) return { primary, isSerif: true };
|
||||
@@ -5190,8 +5204,7 @@ function checkTypography() {
|
||||
const style = getComputedStyle(el);
|
||||
const ff = style.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (!primary) continue;
|
||||
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
||||
totalTextElements++;
|
||||
@@ -5436,8 +5449,7 @@ function checkPageTypography(doc, win) {
|
||||
if (rule.type !== 1) continue;
|
||||
const ff = rule.style?.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
@@ -5456,11 +5468,10 @@ function checkPageTypography(doc, win) {
|
||||
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
let fm;
|
||||
while ((fm = ffRe.exec(html)) !== null) {
|
||||
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
||||
if (f && !GENERIC_FONTS.has(f)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
const primary = primaryFontFace(fm[1]);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { OVERUSED_FONTS, primaryFontFace } from '../../shared/constants.mjs';
|
||||
import {
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
@@ -51,9 +51,7 @@ function checkStaticPageTypography(document, window) {
|
||||
for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) {
|
||||
const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasText) continue;
|
||||
const ff = window.getComputedStyle(el).fontFamily || '';
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(window.getComputedStyle(el).fontFamily);
|
||||
if (!primary) continue;
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
isBrandFontOnOwnDomain,
|
||||
primaryFontFace,
|
||||
} from '../shared/constants.mjs';
|
||||
import {
|
||||
CSS_NAMED_COLORS,
|
||||
@@ -331,7 +332,7 @@ function checkIconTile(opts) {
|
||||
function resolveSerif(fontFamily) {
|
||||
if (!fontFamily) return { primary: null, isSerif: false };
|
||||
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
|
||||
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
|
||||
if (!primary) return { primary: null, isSerif: false };
|
||||
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
|
||||
if (tokens.includes('serif')) return { primary, isSerif: true };
|
||||
@@ -3930,8 +3931,7 @@ function checkTypography() {
|
||||
const style = getComputedStyle(el);
|
||||
const ff = style.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (!primary) continue;
|
||||
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
||||
totalTextElements++;
|
||||
@@ -4176,8 +4176,7 @@ function checkPageTypography(doc, win) {
|
||||
if (rule.type !== 1) continue;
|
||||
const ff = rule.style?.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
@@ -4196,11 +4195,10 @@ function checkPageTypography(doc, win) {
|
||||
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
let fm;
|
||||
while ((fm = ffRe.exec(html)) !== null) {
|
||||
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
||||
if (f && !GENERIC_FONTS.has(f)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
const primary = primaryFontFace(fm[1]);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,13 +56,27 @@ function isBrandFontOnOwnDomain(font) {
|
||||
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||
}
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
// Overused-font primary selection skips only CSS generics so a system stack
|
||||
// keeps the system face as primary; GENERIC_FONTS still includes platform
|
||||
// faces for design-system/serif resolution.
|
||||
const CSS_GENERIC_FONTS = new Set([
|
||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
...CSS_GENERIC_FONTS,
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
]);
|
||||
|
||||
function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
|
||||
return String(fontFamily || '')
|
||||
.split(',')
|
||||
.map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
|
||||
.find(f => f && !skip.has(f)) || null;
|
||||
}
|
||||
|
||||
// WCAG large text thresholds are defined in points: 18pt normal text and
|
||||
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
@@ -104,6 +118,7 @@ export {
|
||||
BRAND_FONT_DOMAINS,
|
||||
isBrandFontOnOwnDomain,
|
||||
GENERIC_FONTS,
|
||||
primaryFontFace,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
|
||||
@@ -21,22 +21,24 @@ import zlib from 'node:zlib';
|
||||
const KEYWORD = 'impeccable:prompt';
|
||||
const args = process.argv.slice(2);
|
||||
const file = args.find(a => !a.startsWith('--'));
|
||||
const readMode = args.includes('--read');
|
||||
const scanMode = args.includes('--scan');
|
||||
const argOf = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : null; };
|
||||
|
||||
function promptOf(imagePath) {
|
||||
const b = fs.readFileSync(imagePath);
|
||||
let prompt = null;
|
||||
if (b.length > 8 && b.readUInt32BE(0) === 0x89504e47) prompt = readPngText(b);
|
||||
else if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8) prompt = readJpegCom(b);
|
||||
function imageType(buffer) {
|
||||
if (buffer.length > 8 && buffer.readUInt32BE(0) === 0x89504e47) return 'png';
|
||||
if (buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8) return 'jpeg';
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPrompt(imagePath, buffer = fs.readFileSync(imagePath)) {
|
||||
const type = imageType(buffer);
|
||||
let prompt = type === 'png' ? parsePng(buffer).prompt : type === 'jpeg' ? readJpegCom(buffer) : null;
|
||||
if (prompt == null && fs.existsSync(`${imagePath}.json`)) {
|
||||
try { prompt = JSON.parse(fs.readFileSync(`${imagePath}.json`, 'utf8')).prompt ?? null; } catch { /* stays null */ }
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
if (scanMode) {
|
||||
if (args.includes('--scan')) {
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
if (targets.length === 0) { console.error('embed-prompt: --scan needs at least one directory'); process.exit(1); }
|
||||
const RASTER = /\.(png|jpe?g|webp)$/i;
|
||||
@@ -59,7 +61,7 @@ if (scanMode) {
|
||||
}
|
||||
let missing = 0;
|
||||
for (const raster of rasters) {
|
||||
if (promptOf(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
|
||||
if (readPrompt(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
|
||||
}
|
||||
console.log(`SCAN: ${rasters.length} raster${rasters.length === 1 ? '' : 's'}, ${missing} missing`);
|
||||
process.exit(missing > 0 ? 3 : 0);
|
||||
@@ -68,8 +70,7 @@ if (scanMode) {
|
||||
if (!file || !fs.existsSync(file)) { console.error('embed-prompt: image file required'); process.exit(1); }
|
||||
|
||||
const buf = fs.readFileSync(file);
|
||||
const isPng = buf.length > 8 && buf.readUInt32BE(0) === 0x89504e47;
|
||||
const isJpeg = buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8;
|
||||
const type = imageType(buf);
|
||||
|
||||
const crcTable = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
@@ -87,22 +88,26 @@ function pngChunk(type, data) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function readPngText(b) {
|
||||
let off = 8;
|
||||
while (off + 12 <= b.length) {
|
||||
const len = b.readUInt32BE(off);
|
||||
const type = b.toString('ascii', off + 4, off + 8);
|
||||
if (type === 'tEXt' || type === 'zTXt') {
|
||||
const data = b.subarray(off + 8, off + 8 + len);
|
||||
const nul = data.indexOf(0);
|
||||
if (nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD) {
|
||||
if (type === 'tEXt') return data.toString('utf8', nul + 1);
|
||||
return zlib.inflateSync(data.subarray(nul + 2)).toString('utf8');
|
||||
}
|
||||
function parsePng(buffer) {
|
||||
const chunks = [];
|
||||
let prompt = null;
|
||||
let offset = 8;
|
||||
while (offset + 12 <= buffer.length) {
|
||||
const length = buffer.readUInt32BE(offset);
|
||||
const type = buffer.toString('ascii', offset + 4, offset + 8);
|
||||
const data = buffer.subarray(offset + 8, offset + 8 + length);
|
||||
const nul = data.indexOf(0);
|
||||
const promptChunk = (type === 'tEXt' || type === 'zTXt')
|
||||
&& nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
|
||||
if (prompt == null && promptChunk) {
|
||||
prompt = type === 'tEXt'
|
||||
? data.toString('utf8', nul + 1)
|
||||
: zlib.inflateSync(data.subarray(nul + 2)).toString('utf8');
|
||||
}
|
||||
off += 12 + len;
|
||||
chunks.push({ offset, type, promptChunk, bytes: buffer.subarray(offset, offset + 12 + length) });
|
||||
offset += 12 + length;
|
||||
}
|
||||
return null;
|
||||
return { chunks, prompt };
|
||||
}
|
||||
|
||||
function readJpegCom(b) {
|
||||
@@ -121,48 +126,34 @@ function readJpegCom(b) {
|
||||
}
|
||||
|
||||
const sidecar = `${file}.json`;
|
||||
if (readMode) {
|
||||
let prompt = null;
|
||||
if (isPng) prompt = readPngText(buf);
|
||||
else if (isJpeg) prompt = readJpegCom(buf);
|
||||
if (prompt == null && fs.existsSync(sidecar)) {
|
||||
try { prompt = JSON.parse(fs.readFileSync(sidecar, 'utf8')).prompt ?? null; } catch { /* fall through */ }
|
||||
}
|
||||
if (args.includes('--read')) {
|
||||
const prompt = readPrompt(file, buf);
|
||||
if (prompt == null) { console.error('embed-prompt: no embedded prompt found'); process.exit(2); }
|
||||
console.log(prompt);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const prompt = argOf('--prompt') ?? (argOf('--prompt-file') ? fs.readFileSync(argOf('--prompt-file'), 'utf8') : null);
|
||||
const promptFile = argOf('--prompt-file');
|
||||
const prompt = argOf('--prompt') ?? (promptFile ? fs.readFileSync(promptFile, 'utf8') : null);
|
||||
if (!prompt) { console.error('embed-prompt: --prompt or --prompt-file required'); process.exit(1); }
|
||||
|
||||
if (isPng) {
|
||||
if (type === 'png') {
|
||||
// Insert (or replace) our tEXt chunk immediately before IEND.
|
||||
const iend = buf.indexOf(Buffer.from('IEND', 'ascii')) - 4;
|
||||
const { chunks, prompt: existingPrompt } = parsePng(buf);
|
||||
const iend = chunks.find((chunk) => chunk.type === 'IEND')?.offset ?? -1;
|
||||
if (iend < 8) { console.error('embed-prompt: malformed PNG'); process.exit(1); }
|
||||
// Drop any existing chunk with our keyword to keep embedding idempotent.
|
||||
let body = buf.subarray(8, iend);
|
||||
const existing = readPngText(buf);
|
||||
if (existing != null) {
|
||||
const parts = [];
|
||||
let off = 8;
|
||||
while (off + 12 <= buf.length && off < iend + 12) {
|
||||
const len = buf.readUInt32BE(off);
|
||||
const type = buf.toString('ascii', off + 4, off + 8);
|
||||
const chunk = buf.subarray(off, off + 12 + len);
|
||||
const data = buf.subarray(off + 8, off + 8 + len);
|
||||
const nul = data.indexOf(0);
|
||||
const ours = (type === 'tEXt' || type === 'zTXt') && nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
|
||||
if (!ours && type !== 'IEND') parts.push(chunk);
|
||||
off += 12 + len;
|
||||
}
|
||||
body = Buffer.concat(parts).subarray(8 * 0); // parts exclude signature
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), pngChunk('IEND', Buffer.alloc(0))]));
|
||||
} else {
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, iend), pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), buf.subarray(iend)]));
|
||||
}
|
||||
const replacing = existingPrompt != null;
|
||||
const body = replacing
|
||||
? Buffer.concat(chunks
|
||||
.filter((chunk) => chunk.offset < iend && !chunk.promptChunk)
|
||||
.map((chunk) => chunk.bytes))
|
||||
: buf.subarray(8, iend);
|
||||
const promptChunk = pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')]));
|
||||
const end = replacing ? pngChunk('IEND', Buffer.alloc(0)) : buf.subarray(iend);
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, promptChunk, end]));
|
||||
console.log(`EMBEDDED: ${file} (png tEXt, ${prompt.length} chars)`);
|
||||
} else if (isJpeg) {
|
||||
} else if (type === 'jpeg') {
|
||||
const seg = Buffer.from(`${KEYWORD}\0${prompt}`, 'utf8');
|
||||
if (seg.length + 2 > 0xffff) { console.error('embed-prompt: prompt too long for a JPEG segment'); process.exit(1); }
|
||||
const com = Buffer.alloc(4 + seg.length);
|
||||
|
||||
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
|
||||
// The prompt travels with the asset: embedded in the file itself (EXIF-class
|
||||
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
|
||||
// plus a sidecar for anything that indexes rather than opens the image.
|
||||
let embedded = false;
|
||||
try {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
embedded = !result.error && result.status === 0;
|
||||
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
|
||||
} catch { /* embedding is best-effort */ }
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
|
||||
if (plateCtx && plateCtx.chroma) {
|
||||
const frac = await keyChroma(out, plateCtx.chroma);
|
||||
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
|
||||
|
||||
@@ -770,9 +770,22 @@ function reset(cwd) {
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return removed.length
|
||||
? `Reset design hook config and cache (removed: ${removed.join(', ')}).`
|
||||
: 'No hook config or cache to remove. Already at defaults.';
|
||||
// `on` writes three things: config, consent, and hook entries in the
|
||||
// provider manifests. Reset must undo all three (issue #512): a leftover
|
||||
// manifest entry kept invoking the hook after the config that said "off"
|
||||
// was deleted. Local destRel only, since `on` never writes the team-shared
|
||||
// sharedDestRel. No skill-folder gate: a reset mid-uninstall (skill files
|
||||
// gone, manifest still wired) is the case that most needs the prune.
|
||||
const pruned = [];
|
||||
for (const target of HOOK_MANIFEST_TARGETS) {
|
||||
try {
|
||||
if (pruneImpeccableHookFromManifest(path.join(cwd, target.destRel))) pruned.push(target.provider);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const parts = [];
|
||||
if (removed.length) parts.push(`Reset design hook config and cache (removed: ${removed.join(', ')}).`);
|
||||
if (pruned.length) parts.push(`Removed hook entries from: ${pruned.join(', ')}.`);
|
||||
return parts.length ? parts.join(' ') : 'No hook config or cache to remove. Already at defaults.';
|
||||
}
|
||||
|
||||
function main() {
|
||||
|
||||
@@ -329,47 +329,37 @@ function stripBold(s) {
|
||||
function extractNamedRules(lines) {
|
||||
const rules = [];
|
||||
const seen = new Set();
|
||||
const addRule = (name, body, { allowDuplicate = false } = {}) => {
|
||||
const key = name.toLowerCase();
|
||||
if (!allowDuplicate && seen.has(key)) return;
|
||||
seen.add(key);
|
||||
rules.push({ name, body });
|
||||
};
|
||||
|
||||
// Style A (Impeccable): "**The X Rule.** body body body" — can span lines.
|
||||
const joined = lines.join('\n');
|
||||
const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g;
|
||||
const inlineMatches = [];
|
||||
let m;
|
||||
while ((m = inlineStart.exec(joined)) !== null) {
|
||||
inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex });
|
||||
}
|
||||
const inlineMatches = [...joined.matchAll(/\*\*(The [^*]+?Rule)\.\*\*/g)];
|
||||
for (let i = 0; i < inlineMatches.length; i++) {
|
||||
const mm = inlineMatches[i];
|
||||
const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;
|
||||
const match = inlineMatches[i];
|
||||
const bodyEnd = inlineMatches[i + 1]?.index ?? joined.length;
|
||||
const body = joined
|
||||
.slice(mm.end, bodyEnd)
|
||||
.slice(match.index + match[0].length, bodyEnd)
|
||||
.replace(/\n##[^\n]*$/s, '')
|
||||
.replace(/\n###[^\n]*$/s, '')
|
||||
.trim();
|
||||
const name = stripBold(mm.name).trim();
|
||||
seen.add(name.toLowerCase());
|
||||
rules.push({ name, body: stripBold(body) });
|
||||
// Preserve the inline format's historical behavior: repeated inline rules
|
||||
// remain visible, while the later heading and bullet formats dedupe.
|
||||
addRule(stripBold(match[1]).trim(), stripBold(body), { allowDuplicate: true });
|
||||
}
|
||||
|
||||
// Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the
|
||||
// bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const h3 = lines[i].match(/^###\s+(.+?)\s*$/);
|
||||
if (!h3) continue;
|
||||
const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim();
|
||||
for (const subsection of splitSubsections(lines).slice(1)) {
|
||||
const headerName = stripBold(subsection.name).replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;
|
||||
if (seen.has(headerName.toLowerCase())) continue;
|
||||
|
||||
const bodyLines = [];
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
if (/^##\s|^###\s/.test(lines[j])) break;
|
||||
bodyLines.push(lines[j]);
|
||||
}
|
||||
const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim();
|
||||
if (body) {
|
||||
seen.add(headerName.toLowerCase());
|
||||
rules.push({ name: headerName, body });
|
||||
}
|
||||
const body = stripBold(subsection.lines.join('\n').replace(/\n+/g, ' ')).trim();
|
||||
if (body) addRule(headerName, body);
|
||||
}
|
||||
|
||||
// Style C (Stitch bullet form): "* **The Layering Principle:** body"
|
||||
@@ -379,9 +369,7 @@ function extractNamedRules(lines) {
|
||||
if (!mm) continue;
|
||||
const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue;
|
||||
if (seen.has(nameRaw.toLowerCase())) continue;
|
||||
seen.add(nameRaw.toLowerCase());
|
||||
rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });
|
||||
addRule(nameRaw, stripBold(mm[2]).trim());
|
||||
}
|
||||
|
||||
return rules;
|
||||
|
||||
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
}),
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
||||
'Pragma': 'no-cache',
|
||||
});
|
||||
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
}
|
||||
if (p === '/detect.js' || p === '/') {
|
||||
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
|
||||
res.end(detectScript);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Use when the user wants to design, redesign, shape, critique, audit
|
||||
version: 4.1.2
|
||||
---
|
||||
|
||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as a award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||
|
||||
Core principles:
|
||||
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
|
||||
@@ -14,7 +14,7 @@ Core principles:
|
||||
## Setup
|
||||
|
||||
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .gemini/skills/impeccable/scripts/...` command in this skill and its references, and `.gemini/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
|
||||
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
|
||||
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
|
||||
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
|
||||
|
||||
## How to design
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
### Purpose
|
||||
|
||||
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
|
||||
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
|
||||
|
||||
### Hard Invariants
|
||||
|
||||
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
|
||||
|
||||
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
|
||||
|
||||
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
|
||||
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
|
||||
|
||||
Structure your feedback as a design director would:
|
||||
|
||||
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
|
||||
node .gemini/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
|
||||
```
|
||||
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
|
||||
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
|
||||
|
||||
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ The first argument is the action. Defaults to `status`.
|
||||
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
|
||||
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
|
||||
| `ignore-value <id> "*" --file <glob> [--file <glob>...]` | Turn one rule off in matching files only, leaving it active everywhere else. Repeat `--file`, or use `--file=<glob>` / `--files=<glob>`. A bare `"*"` with no `--file` is refused: use `ignore-rule <id>` if you really mean project-wide. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue, and remove the hook's entries from every provider manifest `on` installs, the committed Copilot file included (a team-shared `settings.json` that `on` never writes is never touched). |
|
||||
|
||||
## Flow
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
|
||||
4. Run `node .gemini/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
|
||||
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .gemini/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .gemini/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
|
||||
|
||||
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
|
||||
@@ -68,16 +68,20 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su
|
||||
|
||||
## 5. Record the decision
|
||||
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
Before code, record the chosen direction as a development-only contract under `## Direction contract` in the relevant surface brief. A direction contract is durable route or artifact strategy, so create or update the brief even when no other surface strategy needs persistence. Keep the contract to six short blocks and 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The surface brief is the reminder later agents reload across edits and sessions: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
|
||||
Never copy the direction contract into implementation source or any browser-delivered artifact. This includes HTML or framework comments, hidden DOM, `<template>` elements, `data-*` attributes, rendered JSX or TSX output, serialized props or state, React Server Component payloads, client bundles, metadata or JSON-LD, accessibility-only text, and files served beside the artifact. A compiler or optimizer removing development metadata is not a safety boundary. Reviewers and documenters receive the contract from the surface brief.
|
||||
|
||||
On a new or replacement world, DESIGN.md is written at finish, from the built world, by the shipped documenter (section 7); a rulebook written before the build gets defended against reality instead of describing it, and hands the design-system detector an unstable target. A new world shipped with no DESIGN.md is still an incomplete run. An ordinary extension does not rewrite DESIGN.md.
|
||||
|
||||
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
|
||||
Read the existing surface brief before updating it:
|
||||
|
||||
`node .gemini/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
|
||||
|
||||
`node .gemini/skills/impeccable/scripts/surface-brief.mjs write <primary-target> <body-file> [related-target ...]`
|
||||
|
||||
After writing, read the brief once more and verify that all six contract blocks and the seed key are present before building.
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
On a comp-led build, whenever any image generation is available (a harness-native tool or the API fallback context.mjs reports), the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options put before the user for approval, the chosen card's decision comp plus two variations. This step is proven to produce the most compositional and ambitious work. On a code-led build the comp round is skipped by contract, never by drift: the ambition it would have carried lives in the direction contract's FIRST VIEWPORT block and named signature interaction, and the finish reviewer audits those promises in behavior.
|
||||
|
||||
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
|
||||
If a prior critique exists, use it as one input:
|
||||
|
||||
```bash
|
||||
node .gemini/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
|
||||
node .gemini/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
|
||||
```
|
||||
|
||||
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
|
||||
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
|
||||
|
||||
## 3. Triage
|
||||
|
||||
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
|
||||
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
|
||||
|
||||
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
|
||||
|
||||
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
|
||||
|
||||
```bash
|
||||
node .gemini/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
|
||||
```
|
||||
|
||||
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
|
||||
|
||||
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
|
||||
|
||||
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
|
||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
approvedPoolRevision,
|
||||
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
|
||||
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
export function sameMainModulePath(left, right, platform = process.platform) {
|
||||
if (platform !== 'win32') return left === right;
|
||||
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
|
||||
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
|
||||
// invoked path. Compare real paths so a linked skill still runs its CLI,
|
||||
// normalizing the drive-letter casing that Windows junctions can change.
|
||||
return sameMainModulePath(
|
||||
realpathSync(process.argv[1]),
|
||||
realpathSync(fileURLToPath(import.meta.url))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const args = process.argv.slice(2);
|
||||
const fromIdx = args.indexOf('--from');
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* CLI entry points (called from skill instructions):
|
||||
* node critique-storage.mjs slug <resolved-target>
|
||||
* node critique-storage.mjs write <slug> <snapshot-body-file>
|
||||
* node critique-storage.mjs latest <slug>
|
||||
* node critique-storage.mjs latest <slug> [--json]
|
||||
* node critique-storage.mjs trend <slug> [limit]
|
||||
* node critique-storage.mjs close <resolved-target> <snapshot-file>
|
||||
*
|
||||
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
|
||||
* markdown file; the model reads it directly with its file-read tool. This
|
||||
@@ -27,6 +28,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
import { slugFromTarget } from './lib/target-slug.mjs';
|
||||
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
|
||||
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an exact content fingerprint for a local file target. URLs and
|
||||
* non-files return null because their content is not available here.
|
||||
*
|
||||
* The fingerprint deliberately describes bytes, not Git state or mtimes:
|
||||
* critique often assesses an uncommitted file, and a later polish run should
|
||||
* inherit that backlog when the bytes are unchanged regardless of staging.
|
||||
*/
|
||||
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
|
||||
if (!target || /^https?:\/\//i.test(target)) return null;
|
||||
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
|
||||
}
|
||||
|
||||
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
|
||||
if (!target || typeof target !== 'string') return null;
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
try {
|
||||
const url = new URL(target);
|
||||
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
||||
return `url:${url.origin}${pathname}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const filePath = resolveLocalTargetPath(target, { cwd });
|
||||
return filePath ? `file:${filePath}` : null;
|
||||
}
|
||||
|
||||
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
|
||||
const filePath = resolveLocalTargetPath(target, { cwd });
|
||||
if (!filePath) return null;
|
||||
try {
|
||||
if (!fs.statSync(filePath).isFile()) return null;
|
||||
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
|
||||
* keys read back by readTrend(). `body` is the human-readable critique
|
||||
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
|
||||
const dir = getCritiqueDir(cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const timestamp = nowFilenameStamp(now);
|
||||
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
|
||||
// Spread `meta` first so internally computed `timestamp` and `slug`
|
||||
// always win. Otherwise a caller-supplied meta blob (parsed from the
|
||||
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
|
||||
// filename in disagreement with its frontmatter and corrupting trends.
|
||||
const front = serializeFrontmatter({ ...meta, timestamp, slug });
|
||||
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
|
||||
return filePath;
|
||||
const contents = `${front}\n${body.trim()}\n`;
|
||||
|
||||
// A second critique can finish in the same UTC second. Use exclusive
|
||||
// creation and a fixed-width suffix so concurrent writers cannot replace
|
||||
// history and lexical ordering still keeps collision entries newest.
|
||||
for (let collision = 0; collision <= 9999; collision += 1) {
|
||||
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
|
||||
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
|
||||
try {
|
||||
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
|
||||
}
|
||||
|
||||
function serializeFrontmatter(obj) {
|
||||
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
|
||||
try { value = JSON.parse(value); } catch { /* leave as-is */ }
|
||||
} else if (/^-?\d+$/.test(value)) {
|
||||
value = Number(value);
|
||||
} else if (value === 'true' || value === 'false') {
|
||||
value = value === 'true';
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
|
||||
/**
|
||||
* Return snapshot files matching `suffix`, sorted oldest → newest.
|
||||
*/
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
|
||||
|
||||
function listSnapshots(suffix, cwd) {
|
||||
const dir = getCritiqueDir(cwd);
|
||||
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
|
||||
.map((f) => path.join(dir, f));
|
||||
}
|
||||
|
||||
function readLatestSnapshotMatching(suffix, cwd) {
|
||||
const filePath = listSnapshots(suffix, cwd).at(-1);
|
||||
function readSnapshot(filePath) {
|
||||
if (!filePath) return null;
|
||||
const body = fs.readFileSync(filePath, 'utf-8');
|
||||
return { path: filePath, body, meta: parseFrontmatter(body) };
|
||||
}
|
||||
|
||||
function snapshotTargetIdentity(snapshot) {
|
||||
const targetPath = snapshot?.meta.target_path;
|
||||
return snapshot?.meta.target_identity
|
||||
|| (targetPath ? `file:${targetPath}` : null);
|
||||
}
|
||||
|
||||
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
|
||||
}
|
||||
|
||||
function readNewestSnapshotForIdentity(
|
||||
slug,
|
||||
targetIdentity,
|
||||
{ cwd = process.cwd() } = {},
|
||||
) {
|
||||
const matches = listSnapshots(`__${slug}.md`, cwd)
|
||||
.map(readSnapshot)
|
||||
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
|
||||
return matches.at(-1) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
||||
* to find its fix backlog when the slug matches.
|
||||
*/
|
||||
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
|
||||
const latest = readNewestSnapshot(slug, { cwd });
|
||||
return latest?.meta.closed === true ? null : latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one exact snapshot closed without deleting the score history consumed
|
||||
* by `trend`. Exact identity matters: a newer critique may land after polish
|
||||
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
|
||||
* may be the absolute path returned by readLatestSnapshot() or the basename
|
||||
* emitted by `latest --json`. Returns the path marked closed, or null.
|
||||
*/
|
||||
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
|
||||
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
|
||||
const dir = path.resolve(getCritiqueDir(cwd));
|
||||
const snapshotPath = path.isAbsolute(snapshotFile)
|
||||
? path.resolve(snapshotFile)
|
||||
: path.resolve(dir, snapshotFile);
|
||||
const filename = path.basename(snapshotPath);
|
||||
if (
|
||||
path.dirname(snapshotPath) !== dir
|
||||
|| !SNAPSHOT_FILENAME.test(filename)
|
||||
) return null;
|
||||
|
||||
let snapshot;
|
||||
try {
|
||||
if (!fs.lstatSync(snapshotPath).isFile()) return null;
|
||||
snapshot = readSnapshot(snapshotPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!snapshot || snapshot.meta.closed === true) return null;
|
||||
const closedBody = snapshot.body.replace(
|
||||
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
|
||||
'$1\nclosed: true$2',
|
||||
);
|
||||
if (closedBody === snapshot.body) {
|
||||
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
|
||||
}
|
||||
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
|
||||
return snapshot.path;
|
||||
}
|
||||
|
||||
/** Return the most recent snapshot across all targets, or null. */
|
||||
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching('.md', cwd);
|
||||
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
|
||||
const identifiedSlugs = new Set(
|
||||
snapshots
|
||||
.filter((snapshot) => snapshotTargetIdentity(snapshot))
|
||||
.map((snapshot) => snapshot.meta.slug),
|
||||
);
|
||||
const latestByTarget = new Map();
|
||||
for (const snapshot of snapshots) {
|
||||
if (!snapshot?.meta.slug) continue;
|
||||
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
|
||||
// one. Keep each known identity's latest open/closed state independent so
|
||||
// closing one target cannot hide another target's live backlog. Once a
|
||||
// slug has any identity-aware snapshot, its older legacy records are no
|
||||
// longer independently routable and must not resurface as zombie work.
|
||||
const targetIdentity = snapshotTargetIdentity(snapshot);
|
||||
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
|
||||
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
|
||||
latestByTarget.set(streamKey, snapshot);
|
||||
}
|
||||
return [...latestByTarget.values()]
|
||||
.filter((snapshot) => snapshot.meta.closed !== true)
|
||||
.sort((a, b) => a.path.localeCompare(b.path))
|
||||
.at(-1) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
||||
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
|
||||
// callers never have to run the slug step separately. Anything containing a
|
||||
// path or URL marker is resolved through slugFromTarget.
|
||||
function isReadySlug(value) {
|
||||
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
|
||||
}
|
||||
|
||||
function coerceSlug(value) {
|
||||
if (!value) return null;
|
||||
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
|
||||
if (isReadySlug(value)) return value;
|
||||
return slugFromTarget(value);
|
||||
}
|
||||
|
||||
@@ -181,14 +322,124 @@ function main(argv) {
|
||||
if (metaArg) {
|
||||
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
|
||||
}
|
||||
// The helper, not caller-provided metadata, owns the target fingerprint.
|
||||
// This makes the snapshot describe the exact file bytes critique saw.
|
||||
delete meta.target_fingerprint;
|
||||
delete meta.target_path;
|
||||
delete meta.target_identity;
|
||||
const targetIdentity = resolveTargetIdentity(slugArg);
|
||||
if (targetIdentity) meta.target_identity = targetIdentity;
|
||||
const targetFingerprint = fingerprintTarget(slugArg);
|
||||
if (targetFingerprint) {
|
||||
meta.target_fingerprint = targetFingerprint;
|
||||
meta.target_path = resolveLocalTargetPath(slugArg);
|
||||
}
|
||||
const out = writeSnapshot({ slug, meta, body: raw });
|
||||
process.stdout.write(`${out}\n`);
|
||||
return;
|
||||
}
|
||||
case 'latest': {
|
||||
const latest = readLatestSnapshot(coerceSlug(args[0]));
|
||||
if (!latest) { process.exit(2); }
|
||||
process.stdout.write(latest.body);
|
||||
const target = args[0];
|
||||
const format = args[1];
|
||||
const slug = coerceSlug(target);
|
||||
if (!slug || (format && format !== '--json')) {
|
||||
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const targetFingerprint = fingerprintTarget(target);
|
||||
const targetPath = resolveLocalTargetPath(target);
|
||||
const targetIdentity = resolveTargetIdentity(target);
|
||||
const readySlug = isReadySlug(target);
|
||||
const newestForSlug = readNewestSnapshot(slug);
|
||||
if (!newestForSlug) { process.exit(2); }
|
||||
|
||||
// Concrete targets select the newest snapshot for their exact identity,
|
||||
// not merely the newest filename for a lossy slug. This keeps distinct
|
||||
// targets such as foo/bar and foo-bar from hiding each other's backlog.
|
||||
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
|
||||
let latest = exactSnapshot;
|
||||
if (!latest && !readySlug) {
|
||||
// Legacy snapshots have no identity. Preserve their old explicit
|
||||
// path/URL behavior only when no known target identity was selected.
|
||||
latest = readNewestSnapshotForIdentity(slug, null);
|
||||
}
|
||||
if (!latest) latest = newestForSlug;
|
||||
if (latest.meta.closed === true) { process.exit(2); }
|
||||
|
||||
const recordedTargetPath = latest.meta.target_path;
|
||||
const recordedTargetIdentity = snapshotTargetIdentity(latest);
|
||||
const matchingIdentity = recordedTargetIdentity === targetIdentity;
|
||||
|
||||
// Bare slugs remain a supported lookup mode, including for URL
|
||||
// snapshots. But when a same-named local file exists, the request is
|
||||
// ambiguous unless that exact file owns the snapshot identity.
|
||||
if (readySlug && !recordedTargetIdentity) {
|
||||
process.stderr.write(
|
||||
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
|
||||
process.stderr.write(
|
||||
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const concreteTarget = !readySlug || matchingIdentity;
|
||||
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
|
||||
process.exit(2);
|
||||
}
|
||||
const concreteLocalTarget = concreteTarget && targetPath;
|
||||
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
|
||||
closeSnapshot(latest.path);
|
||||
process.exit(2);
|
||||
}
|
||||
if (format === '--json') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
snapshot_file: path.basename(latest.path),
|
||||
body: latest.body,
|
||||
}, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(latest.body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'close': {
|
||||
const [slugArg, snapshotFile, ...extra] = args;
|
||||
const slug = coerceSlug(slugArg);
|
||||
if (!slug || !snapshotFile || extra.length > 0) {
|
||||
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
if (
|
||||
path.basename(snapshotFile) !== snapshotFile
|
||||
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|
||||
|| !snapshotFile.endsWith(`__${slug}.md`)
|
||||
) process.exit(2);
|
||||
|
||||
// A slug and filename are not enough to prove ownership because two
|
||||
// distinct targets can normalize to the same slug. Modern snapshots
|
||||
// carry a canonical identity, so require the supplied resolved target
|
||||
// to match it before allowing the exact snapshot to be closed. Legacy
|
||||
// snapshots without identity retain their historical close behavior.
|
||||
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
|
||||
let snapshot;
|
||||
try {
|
||||
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
|
||||
snapshot = readSnapshot(snapshotPath);
|
||||
} catch {
|
||||
process.exit(2);
|
||||
}
|
||||
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
|
||||
if (
|
||||
recordedTargetIdentity
|
||||
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
|
||||
) process.exit(2);
|
||||
|
||||
const closed = closeSnapshot(snapshotFile);
|
||||
if (!closed) { process.exit(2); }
|
||||
process.stdout.write(`${closed}\n`);
|
||||
return;
|
||||
}
|
||||
case 'trend': {
|
||||
@@ -197,7 +448,7 @@ function main(argv) {
|
||||
return;
|
||||
}
|
||||
default:
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
|
||||
return String(value || '')
|
||||
.replace(/\s*\/\s*/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(token => token.trim())
|
||||
// var() fallbacks leave the closing parenthesis on the final token. Strip
|
||||
// it before length resolution so `8px)` is not treated as unitless 8rem.
|
||||
.map(token => token.trim().replace(/\)+$/, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,13 +70,27 @@ function isBrandFontOnOwnDomain(font) {
|
||||
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||
}
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
// Overused-font primary selection skips only CSS generics so a system stack
|
||||
// keeps the system face as primary; GENERIC_FONTS still includes platform
|
||||
// faces for design-system/serif resolution.
|
||||
const CSS_GENERIC_FONTS = new Set([
|
||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
...CSS_GENERIC_FONTS,
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
]);
|
||||
|
||||
function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
|
||||
return String(fontFamily || '')
|
||||
.split(',')
|
||||
.map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
|
||||
.find(f => f && !skip.has(f)) || null;
|
||||
}
|
||||
|
||||
// WCAG large text thresholds are defined in points: 18pt normal text and
|
||||
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
@@ -1591,7 +1605,7 @@ function checkIconTile(opts) {
|
||||
function resolveSerif(fontFamily) {
|
||||
if (!fontFamily) return { primary: null, isSerif: false };
|
||||
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
|
||||
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
|
||||
if (!primary) return { primary: null, isSerif: false };
|
||||
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
|
||||
if (tokens.includes('serif')) return { primary, isSerif: true };
|
||||
@@ -5190,8 +5204,7 @@ function checkTypography() {
|
||||
const style = getComputedStyle(el);
|
||||
const ff = style.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (!primary) continue;
|
||||
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
||||
totalTextElements++;
|
||||
@@ -5436,8 +5449,7 @@ function checkPageTypography(doc, win) {
|
||||
if (rule.type !== 1) continue;
|
||||
const ff = rule.style?.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
@@ -5456,11 +5468,10 @@ function checkPageTypography(doc, win) {
|
||||
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
let fm;
|
||||
while ((fm = ffRe.exec(html)) !== null) {
|
||||
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
||||
if (f && !GENERIC_FONTS.has(f)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
const primary = primaryFontFace(fm[1]);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { OVERUSED_FONTS, primaryFontFace } from '../../shared/constants.mjs';
|
||||
import {
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
@@ -51,9 +51,7 @@ function checkStaticPageTypography(document, window) {
|
||||
for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) {
|
||||
const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasText) continue;
|
||||
const ff = window.getComputedStyle(el).fontFamily || '';
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(window.getComputedStyle(el).fontFamily);
|
||||
if (!primary) continue;
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
isBrandFontOnOwnDomain,
|
||||
primaryFontFace,
|
||||
} from '../shared/constants.mjs';
|
||||
import {
|
||||
CSS_NAMED_COLORS,
|
||||
@@ -331,7 +332,7 @@ function checkIconTile(opts) {
|
||||
function resolveSerif(fontFamily) {
|
||||
if (!fontFamily) return { primary: null, isSerif: false };
|
||||
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
|
||||
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
|
||||
if (!primary) return { primary: null, isSerif: false };
|
||||
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
|
||||
if (tokens.includes('serif')) return { primary, isSerif: true };
|
||||
@@ -3930,8 +3931,7 @@ function checkTypography() {
|
||||
const style = getComputedStyle(el);
|
||||
const ff = style.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (!primary) continue;
|
||||
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
||||
totalTextElements++;
|
||||
@@ -4176,8 +4176,7 @@ function checkPageTypography(doc, win) {
|
||||
if (rule.type !== 1) continue;
|
||||
const ff = rule.style?.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
@@ -4196,11 +4195,10 @@ function checkPageTypography(doc, win) {
|
||||
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
let fm;
|
||||
while ((fm = ffRe.exec(html)) !== null) {
|
||||
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
||||
if (f && !GENERIC_FONTS.has(f)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
const primary = primaryFontFace(fm[1]);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,13 +56,27 @@ function isBrandFontOnOwnDomain(font) {
|
||||
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||
}
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
// Overused-font primary selection skips only CSS generics so a system stack
|
||||
// keeps the system face as primary; GENERIC_FONTS still includes platform
|
||||
// faces for design-system/serif resolution.
|
||||
const CSS_GENERIC_FONTS = new Set([
|
||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
...CSS_GENERIC_FONTS,
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
]);
|
||||
|
||||
function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
|
||||
return String(fontFamily || '')
|
||||
.split(',')
|
||||
.map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
|
||||
.find(f => f && !skip.has(f)) || null;
|
||||
}
|
||||
|
||||
// WCAG large text thresholds are defined in points: 18pt normal text and
|
||||
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
@@ -104,6 +118,7 @@ export {
|
||||
BRAND_FONT_DOMAINS,
|
||||
isBrandFontOnOwnDomain,
|
||||
GENERIC_FONTS,
|
||||
primaryFontFace,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
|
||||
@@ -21,22 +21,24 @@ import zlib from 'node:zlib';
|
||||
const KEYWORD = 'impeccable:prompt';
|
||||
const args = process.argv.slice(2);
|
||||
const file = args.find(a => !a.startsWith('--'));
|
||||
const readMode = args.includes('--read');
|
||||
const scanMode = args.includes('--scan');
|
||||
const argOf = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : null; };
|
||||
|
||||
function promptOf(imagePath) {
|
||||
const b = fs.readFileSync(imagePath);
|
||||
let prompt = null;
|
||||
if (b.length > 8 && b.readUInt32BE(0) === 0x89504e47) prompt = readPngText(b);
|
||||
else if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8) prompt = readJpegCom(b);
|
||||
function imageType(buffer) {
|
||||
if (buffer.length > 8 && buffer.readUInt32BE(0) === 0x89504e47) return 'png';
|
||||
if (buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8) return 'jpeg';
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPrompt(imagePath, buffer = fs.readFileSync(imagePath)) {
|
||||
const type = imageType(buffer);
|
||||
let prompt = type === 'png' ? parsePng(buffer).prompt : type === 'jpeg' ? readJpegCom(buffer) : null;
|
||||
if (prompt == null && fs.existsSync(`${imagePath}.json`)) {
|
||||
try { prompt = JSON.parse(fs.readFileSync(`${imagePath}.json`, 'utf8')).prompt ?? null; } catch { /* stays null */ }
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
if (scanMode) {
|
||||
if (args.includes('--scan')) {
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
if (targets.length === 0) { console.error('embed-prompt: --scan needs at least one directory'); process.exit(1); }
|
||||
const RASTER = /\.(png|jpe?g|webp)$/i;
|
||||
@@ -59,7 +61,7 @@ if (scanMode) {
|
||||
}
|
||||
let missing = 0;
|
||||
for (const raster of rasters) {
|
||||
if (promptOf(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
|
||||
if (readPrompt(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
|
||||
}
|
||||
console.log(`SCAN: ${rasters.length} raster${rasters.length === 1 ? '' : 's'}, ${missing} missing`);
|
||||
process.exit(missing > 0 ? 3 : 0);
|
||||
@@ -68,8 +70,7 @@ if (scanMode) {
|
||||
if (!file || !fs.existsSync(file)) { console.error('embed-prompt: image file required'); process.exit(1); }
|
||||
|
||||
const buf = fs.readFileSync(file);
|
||||
const isPng = buf.length > 8 && buf.readUInt32BE(0) === 0x89504e47;
|
||||
const isJpeg = buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8;
|
||||
const type = imageType(buf);
|
||||
|
||||
const crcTable = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
@@ -87,22 +88,26 @@ function pngChunk(type, data) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function readPngText(b) {
|
||||
let off = 8;
|
||||
while (off + 12 <= b.length) {
|
||||
const len = b.readUInt32BE(off);
|
||||
const type = b.toString('ascii', off + 4, off + 8);
|
||||
if (type === 'tEXt' || type === 'zTXt') {
|
||||
const data = b.subarray(off + 8, off + 8 + len);
|
||||
const nul = data.indexOf(0);
|
||||
if (nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD) {
|
||||
if (type === 'tEXt') return data.toString('utf8', nul + 1);
|
||||
return zlib.inflateSync(data.subarray(nul + 2)).toString('utf8');
|
||||
}
|
||||
function parsePng(buffer) {
|
||||
const chunks = [];
|
||||
let prompt = null;
|
||||
let offset = 8;
|
||||
while (offset + 12 <= buffer.length) {
|
||||
const length = buffer.readUInt32BE(offset);
|
||||
const type = buffer.toString('ascii', offset + 4, offset + 8);
|
||||
const data = buffer.subarray(offset + 8, offset + 8 + length);
|
||||
const nul = data.indexOf(0);
|
||||
const promptChunk = (type === 'tEXt' || type === 'zTXt')
|
||||
&& nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
|
||||
if (prompt == null && promptChunk) {
|
||||
prompt = type === 'tEXt'
|
||||
? data.toString('utf8', nul + 1)
|
||||
: zlib.inflateSync(data.subarray(nul + 2)).toString('utf8');
|
||||
}
|
||||
off += 12 + len;
|
||||
chunks.push({ offset, type, promptChunk, bytes: buffer.subarray(offset, offset + 12 + length) });
|
||||
offset += 12 + length;
|
||||
}
|
||||
return null;
|
||||
return { chunks, prompt };
|
||||
}
|
||||
|
||||
function readJpegCom(b) {
|
||||
@@ -121,48 +126,34 @@ function readJpegCom(b) {
|
||||
}
|
||||
|
||||
const sidecar = `${file}.json`;
|
||||
if (readMode) {
|
||||
let prompt = null;
|
||||
if (isPng) prompt = readPngText(buf);
|
||||
else if (isJpeg) prompt = readJpegCom(buf);
|
||||
if (prompt == null && fs.existsSync(sidecar)) {
|
||||
try { prompt = JSON.parse(fs.readFileSync(sidecar, 'utf8')).prompt ?? null; } catch { /* fall through */ }
|
||||
}
|
||||
if (args.includes('--read')) {
|
||||
const prompt = readPrompt(file, buf);
|
||||
if (prompt == null) { console.error('embed-prompt: no embedded prompt found'); process.exit(2); }
|
||||
console.log(prompt);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const prompt = argOf('--prompt') ?? (argOf('--prompt-file') ? fs.readFileSync(argOf('--prompt-file'), 'utf8') : null);
|
||||
const promptFile = argOf('--prompt-file');
|
||||
const prompt = argOf('--prompt') ?? (promptFile ? fs.readFileSync(promptFile, 'utf8') : null);
|
||||
if (!prompt) { console.error('embed-prompt: --prompt or --prompt-file required'); process.exit(1); }
|
||||
|
||||
if (isPng) {
|
||||
if (type === 'png') {
|
||||
// Insert (or replace) our tEXt chunk immediately before IEND.
|
||||
const iend = buf.indexOf(Buffer.from('IEND', 'ascii')) - 4;
|
||||
const { chunks, prompt: existingPrompt } = parsePng(buf);
|
||||
const iend = chunks.find((chunk) => chunk.type === 'IEND')?.offset ?? -1;
|
||||
if (iend < 8) { console.error('embed-prompt: malformed PNG'); process.exit(1); }
|
||||
// Drop any existing chunk with our keyword to keep embedding idempotent.
|
||||
let body = buf.subarray(8, iend);
|
||||
const existing = readPngText(buf);
|
||||
if (existing != null) {
|
||||
const parts = [];
|
||||
let off = 8;
|
||||
while (off + 12 <= buf.length && off < iend + 12) {
|
||||
const len = buf.readUInt32BE(off);
|
||||
const type = buf.toString('ascii', off + 4, off + 8);
|
||||
const chunk = buf.subarray(off, off + 12 + len);
|
||||
const data = buf.subarray(off + 8, off + 8 + len);
|
||||
const nul = data.indexOf(0);
|
||||
const ours = (type === 'tEXt' || type === 'zTXt') && nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
|
||||
if (!ours && type !== 'IEND') parts.push(chunk);
|
||||
off += 12 + len;
|
||||
}
|
||||
body = Buffer.concat(parts).subarray(8 * 0); // parts exclude signature
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), pngChunk('IEND', Buffer.alloc(0))]));
|
||||
} else {
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, iend), pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), buf.subarray(iend)]));
|
||||
}
|
||||
const replacing = existingPrompt != null;
|
||||
const body = replacing
|
||||
? Buffer.concat(chunks
|
||||
.filter((chunk) => chunk.offset < iend && !chunk.promptChunk)
|
||||
.map((chunk) => chunk.bytes))
|
||||
: buf.subarray(8, iend);
|
||||
const promptChunk = pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')]));
|
||||
const end = replacing ? pngChunk('IEND', Buffer.alloc(0)) : buf.subarray(iend);
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, promptChunk, end]));
|
||||
console.log(`EMBEDDED: ${file} (png tEXt, ${prompt.length} chars)`);
|
||||
} else if (isJpeg) {
|
||||
} else if (type === 'jpeg') {
|
||||
const seg = Buffer.from(`${KEYWORD}\0${prompt}`, 'utf8');
|
||||
if (seg.length + 2 > 0xffff) { console.error('embed-prompt: prompt too long for a JPEG segment'); process.exit(1); }
|
||||
const com = Buffer.alloc(4 + seg.length);
|
||||
|
||||
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
|
||||
// The prompt travels with the asset: embedded in the file itself (EXIF-class
|
||||
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
|
||||
// plus a sidecar for anything that indexes rather than opens the image.
|
||||
let embedded = false;
|
||||
try {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
embedded = !result.error && result.status === 0;
|
||||
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
|
||||
} catch { /* embedding is best-effort */ }
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
|
||||
if (plateCtx && plateCtx.chroma) {
|
||||
const frac = await keyChroma(out, plateCtx.chroma);
|
||||
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
|
||||
|
||||
@@ -770,9 +770,22 @@ function reset(cwd) {
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return removed.length
|
||||
? `Reset design hook config and cache (removed: ${removed.join(', ')}).`
|
||||
: 'No hook config or cache to remove. Already at defaults.';
|
||||
// `on` writes three things: config, consent, and hook entries in the
|
||||
// provider manifests. Reset must undo all three (issue #512): a leftover
|
||||
// manifest entry kept invoking the hook after the config that said "off"
|
||||
// was deleted. Local destRel only, since `on` never writes the team-shared
|
||||
// sharedDestRel. No skill-folder gate: a reset mid-uninstall (skill files
|
||||
// gone, manifest still wired) is the case that most needs the prune.
|
||||
const pruned = [];
|
||||
for (const target of HOOK_MANIFEST_TARGETS) {
|
||||
try {
|
||||
if (pruneImpeccableHookFromManifest(path.join(cwd, target.destRel))) pruned.push(target.provider);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const parts = [];
|
||||
if (removed.length) parts.push(`Reset design hook config and cache (removed: ${removed.join(', ')}).`);
|
||||
if (pruned.length) parts.push(`Removed hook entries from: ${pruned.join(', ')}.`);
|
||||
return parts.length ? parts.join(' ') : 'No hook config or cache to remove. Already at defaults.';
|
||||
}
|
||||
|
||||
function main() {
|
||||
|
||||
@@ -329,47 +329,37 @@ function stripBold(s) {
|
||||
function extractNamedRules(lines) {
|
||||
const rules = [];
|
||||
const seen = new Set();
|
||||
const addRule = (name, body, { allowDuplicate = false } = {}) => {
|
||||
const key = name.toLowerCase();
|
||||
if (!allowDuplicate && seen.has(key)) return;
|
||||
seen.add(key);
|
||||
rules.push({ name, body });
|
||||
};
|
||||
|
||||
// Style A (Impeccable): "**The X Rule.** body body body" — can span lines.
|
||||
const joined = lines.join('\n');
|
||||
const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g;
|
||||
const inlineMatches = [];
|
||||
let m;
|
||||
while ((m = inlineStart.exec(joined)) !== null) {
|
||||
inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex });
|
||||
}
|
||||
const inlineMatches = [...joined.matchAll(/\*\*(The [^*]+?Rule)\.\*\*/g)];
|
||||
for (let i = 0; i < inlineMatches.length; i++) {
|
||||
const mm = inlineMatches[i];
|
||||
const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;
|
||||
const match = inlineMatches[i];
|
||||
const bodyEnd = inlineMatches[i + 1]?.index ?? joined.length;
|
||||
const body = joined
|
||||
.slice(mm.end, bodyEnd)
|
||||
.slice(match.index + match[0].length, bodyEnd)
|
||||
.replace(/\n##[^\n]*$/s, '')
|
||||
.replace(/\n###[^\n]*$/s, '')
|
||||
.trim();
|
||||
const name = stripBold(mm.name).trim();
|
||||
seen.add(name.toLowerCase());
|
||||
rules.push({ name, body: stripBold(body) });
|
||||
// Preserve the inline format's historical behavior: repeated inline rules
|
||||
// remain visible, while the later heading and bullet formats dedupe.
|
||||
addRule(stripBold(match[1]).trim(), stripBold(body), { allowDuplicate: true });
|
||||
}
|
||||
|
||||
// Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the
|
||||
// bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const h3 = lines[i].match(/^###\s+(.+?)\s*$/);
|
||||
if (!h3) continue;
|
||||
const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim();
|
||||
for (const subsection of splitSubsections(lines).slice(1)) {
|
||||
const headerName = stripBold(subsection.name).replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;
|
||||
if (seen.has(headerName.toLowerCase())) continue;
|
||||
|
||||
const bodyLines = [];
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
if (/^##\s|^###\s/.test(lines[j])) break;
|
||||
bodyLines.push(lines[j]);
|
||||
}
|
||||
const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim();
|
||||
if (body) {
|
||||
seen.add(headerName.toLowerCase());
|
||||
rules.push({ name: headerName, body });
|
||||
}
|
||||
const body = stripBold(subsection.lines.join('\n').replace(/\n+/g, ' ')).trim();
|
||||
if (body) addRule(headerName, body);
|
||||
}
|
||||
|
||||
// Style C (Stitch bullet form): "* **The Layering Principle:** body"
|
||||
@@ -379,9 +369,7 @@ function extractNamedRules(lines) {
|
||||
if (!mm) continue;
|
||||
const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue;
|
||||
if (seen.has(nameRaw.toLowerCase())) continue;
|
||||
seen.add(nameRaw.toLowerCase());
|
||||
rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });
|
||||
addRule(nameRaw, stripBold(mm[2]).trim());
|
||||
}
|
||||
|
||||
return rules;
|
||||
|
||||
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
}),
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
||||
'Pragma': 'no-cache',
|
||||
});
|
||||
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
}
|
||||
if (p === '/detect.js' || p === '/') {
|
||||
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
|
||||
res.end(detectScript);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layo
|
||||
license: Apache 2.0
|
||||
---
|
||||
|
||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as a award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||
|
||||
Core principles:
|
||||
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
|
||||
@@ -17,7 +17,7 @@ Core principles:
|
||||
## Setup
|
||||
|
||||
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .github/skills/impeccable/scripts/...` command in this skill and its references, and `.github/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
|
||||
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
|
||||
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
|
||||
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
|
||||
|
||||
## How to design
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
### Purpose
|
||||
|
||||
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
|
||||
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
|
||||
|
||||
### Hard Invariants
|
||||
|
||||
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
|
||||
|
||||
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
|
||||
|
||||
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
|
||||
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
|
||||
|
||||
Structure your feedback as a design director would:
|
||||
|
||||
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
|
||||
node .github/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
|
||||
```
|
||||
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
|
||||
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
|
||||
|
||||
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ The first argument is the action. Defaults to `status`.
|
||||
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
|
||||
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
|
||||
| `ignore-value <id> "*" --file <glob> [--file <glob>...]` | Turn one rule off in matching files only, leaving it active everywhere else. Repeat `--file`, or use `--file=<glob>` / `--files=<glob>`. A bare `"*"` with no `--file` is refused: use `ignore-rule <id>` if you really mean project-wide. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue, and remove the hook's entries from every provider manifest `on` installs, the committed Copilot file included (a team-shared `settings.json` that `on` never writes is never touched). |
|
||||
|
||||
## Flow
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
|
||||
4. Run `node .github/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
|
||||
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .github/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .github/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
|
||||
|
||||
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
|
||||
@@ -68,16 +68,20 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su
|
||||
|
||||
## 5. Record the decision
|
||||
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
Before code, record the chosen direction as a development-only contract under `## Direction contract` in the relevant surface brief. A direction contract is durable route or artifact strategy, so create or update the brief even when no other surface strategy needs persistence. Keep the contract to six short blocks and 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The surface brief is the reminder later agents reload across edits and sessions: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
|
||||
Never copy the direction contract into implementation source or any browser-delivered artifact. This includes HTML or framework comments, hidden DOM, `<template>` elements, `data-*` attributes, rendered JSX or TSX output, serialized props or state, React Server Component payloads, client bundles, metadata or JSON-LD, accessibility-only text, and files served beside the artifact. A compiler or optimizer removing development metadata is not a safety boundary. Reviewers and documenters receive the contract from the surface brief.
|
||||
|
||||
On a new or replacement world, DESIGN.md is written at finish, from the built world, by the shipped documenter (section 7); a rulebook written before the build gets defended against reality instead of describing it, and hands the design-system detector an unstable target. A new world shipped with no DESIGN.md is still an incomplete run. An ordinary extension does not rewrite DESIGN.md.
|
||||
|
||||
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
|
||||
Read the existing surface brief before updating it:
|
||||
|
||||
`node .github/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
|
||||
|
||||
`node .github/skills/impeccable/scripts/surface-brief.mjs write <primary-target> <body-file> [related-target ...]`
|
||||
|
||||
After writing, read the brief once more and verify that all six contract blocks and the seed key are present before building.
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
On a comp-led build, whenever any image generation is available (a harness-native tool or the API fallback context.mjs reports), the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options put before the user for approval, the chosen card's decision comp plus two variations. This step is proven to produce the most compositional and ambitious work. On a code-led build the comp round is skipped by contract, never by drift: the ambition it would have carried lives in the direction contract's FIRST VIEWPORT block and named signature interaction, and the finish reviewer audits those promises in behavior.
|
||||
|
||||
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
|
||||
If a prior critique exists, use it as one input:
|
||||
|
||||
```bash
|
||||
node .github/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
|
||||
node .github/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
|
||||
```
|
||||
|
||||
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
|
||||
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
|
||||
|
||||
## 3. Triage
|
||||
|
||||
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
|
||||
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
|
||||
|
||||
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
|
||||
|
||||
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
|
||||
|
||||
```bash
|
||||
node .github/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
|
||||
```
|
||||
|
||||
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
|
||||
|
||||
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
|
||||
|
||||
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
|
||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
approvedPoolRevision,
|
||||
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
|
||||
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
export function sameMainModulePath(left, right, platform = process.platform) {
|
||||
if (platform !== 'win32') return left === right;
|
||||
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
|
||||
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
|
||||
// invoked path. Compare real paths so a linked skill still runs its CLI,
|
||||
// normalizing the drive-letter casing that Windows junctions can change.
|
||||
return sameMainModulePath(
|
||||
realpathSync(process.argv[1]),
|
||||
realpathSync(fileURLToPath(import.meta.url))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const args = process.argv.slice(2);
|
||||
const fromIdx = args.indexOf('--from');
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* CLI entry points (called from skill instructions):
|
||||
* node critique-storage.mjs slug <resolved-target>
|
||||
* node critique-storage.mjs write <slug> <snapshot-body-file>
|
||||
* node critique-storage.mjs latest <slug>
|
||||
* node critique-storage.mjs latest <slug> [--json]
|
||||
* node critique-storage.mjs trend <slug> [limit]
|
||||
* node critique-storage.mjs close <resolved-target> <snapshot-file>
|
||||
*
|
||||
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
|
||||
* markdown file; the model reads it directly with its file-read tool. This
|
||||
@@ -27,6 +28,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
import { slugFromTarget } from './lib/target-slug.mjs';
|
||||
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
|
||||
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an exact content fingerprint for a local file target. URLs and
|
||||
* non-files return null because their content is not available here.
|
||||
*
|
||||
* The fingerprint deliberately describes bytes, not Git state or mtimes:
|
||||
* critique often assesses an uncommitted file, and a later polish run should
|
||||
* inherit that backlog when the bytes are unchanged regardless of staging.
|
||||
*/
|
||||
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
|
||||
if (!target || /^https?:\/\//i.test(target)) return null;
|
||||
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
|
||||
}
|
||||
|
||||
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
|
||||
if (!target || typeof target !== 'string') return null;
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
try {
|
||||
const url = new URL(target);
|
||||
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
||||
return `url:${url.origin}${pathname}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const filePath = resolveLocalTargetPath(target, { cwd });
|
||||
return filePath ? `file:${filePath}` : null;
|
||||
}
|
||||
|
||||
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
|
||||
const filePath = resolveLocalTargetPath(target, { cwd });
|
||||
if (!filePath) return null;
|
||||
try {
|
||||
if (!fs.statSync(filePath).isFile()) return null;
|
||||
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
|
||||
* keys read back by readTrend(). `body` is the human-readable critique
|
||||
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
|
||||
const dir = getCritiqueDir(cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const timestamp = nowFilenameStamp(now);
|
||||
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
|
||||
// Spread `meta` first so internally computed `timestamp` and `slug`
|
||||
// always win. Otherwise a caller-supplied meta blob (parsed from the
|
||||
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
|
||||
// filename in disagreement with its frontmatter and corrupting trends.
|
||||
const front = serializeFrontmatter({ ...meta, timestamp, slug });
|
||||
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
|
||||
return filePath;
|
||||
const contents = `${front}\n${body.trim()}\n`;
|
||||
|
||||
// A second critique can finish in the same UTC second. Use exclusive
|
||||
// creation and a fixed-width suffix so concurrent writers cannot replace
|
||||
// history and lexical ordering still keeps collision entries newest.
|
||||
for (let collision = 0; collision <= 9999; collision += 1) {
|
||||
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
|
||||
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
|
||||
try {
|
||||
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
|
||||
}
|
||||
|
||||
function serializeFrontmatter(obj) {
|
||||
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
|
||||
try { value = JSON.parse(value); } catch { /* leave as-is */ }
|
||||
} else if (/^-?\d+$/.test(value)) {
|
||||
value = Number(value);
|
||||
} else if (value === 'true' || value === 'false') {
|
||||
value = value === 'true';
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
|
||||
/**
|
||||
* Return snapshot files matching `suffix`, sorted oldest → newest.
|
||||
*/
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
|
||||
|
||||
function listSnapshots(suffix, cwd) {
|
||||
const dir = getCritiqueDir(cwd);
|
||||
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
|
||||
.map((f) => path.join(dir, f));
|
||||
}
|
||||
|
||||
function readLatestSnapshotMatching(suffix, cwd) {
|
||||
const filePath = listSnapshots(suffix, cwd).at(-1);
|
||||
function readSnapshot(filePath) {
|
||||
if (!filePath) return null;
|
||||
const body = fs.readFileSync(filePath, 'utf-8');
|
||||
return { path: filePath, body, meta: parseFrontmatter(body) };
|
||||
}
|
||||
|
||||
function snapshotTargetIdentity(snapshot) {
|
||||
const targetPath = snapshot?.meta.target_path;
|
||||
return snapshot?.meta.target_identity
|
||||
|| (targetPath ? `file:${targetPath}` : null);
|
||||
}
|
||||
|
||||
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
|
||||
}
|
||||
|
||||
function readNewestSnapshotForIdentity(
|
||||
slug,
|
||||
targetIdentity,
|
||||
{ cwd = process.cwd() } = {},
|
||||
) {
|
||||
const matches = listSnapshots(`__${slug}.md`, cwd)
|
||||
.map(readSnapshot)
|
||||
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
|
||||
return matches.at(-1) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
||||
* to find its fix backlog when the slug matches.
|
||||
*/
|
||||
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
|
||||
const latest = readNewestSnapshot(slug, { cwd });
|
||||
return latest?.meta.closed === true ? null : latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one exact snapshot closed without deleting the score history consumed
|
||||
* by `trend`. Exact identity matters: a newer critique may land after polish
|
||||
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
|
||||
* may be the absolute path returned by readLatestSnapshot() or the basename
|
||||
* emitted by `latest --json`. Returns the path marked closed, or null.
|
||||
*/
|
||||
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
|
||||
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
|
||||
const dir = path.resolve(getCritiqueDir(cwd));
|
||||
const snapshotPath = path.isAbsolute(snapshotFile)
|
||||
? path.resolve(snapshotFile)
|
||||
: path.resolve(dir, snapshotFile);
|
||||
const filename = path.basename(snapshotPath);
|
||||
if (
|
||||
path.dirname(snapshotPath) !== dir
|
||||
|| !SNAPSHOT_FILENAME.test(filename)
|
||||
) return null;
|
||||
|
||||
let snapshot;
|
||||
try {
|
||||
if (!fs.lstatSync(snapshotPath).isFile()) return null;
|
||||
snapshot = readSnapshot(snapshotPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!snapshot || snapshot.meta.closed === true) return null;
|
||||
const closedBody = snapshot.body.replace(
|
||||
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
|
||||
'$1\nclosed: true$2',
|
||||
);
|
||||
if (closedBody === snapshot.body) {
|
||||
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
|
||||
}
|
||||
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
|
||||
return snapshot.path;
|
||||
}
|
||||
|
||||
/** Return the most recent snapshot across all targets, or null. */
|
||||
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching('.md', cwd);
|
||||
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
|
||||
const identifiedSlugs = new Set(
|
||||
snapshots
|
||||
.filter((snapshot) => snapshotTargetIdentity(snapshot))
|
||||
.map((snapshot) => snapshot.meta.slug),
|
||||
);
|
||||
const latestByTarget = new Map();
|
||||
for (const snapshot of snapshots) {
|
||||
if (!snapshot?.meta.slug) continue;
|
||||
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
|
||||
// one. Keep each known identity's latest open/closed state independent so
|
||||
// closing one target cannot hide another target's live backlog. Once a
|
||||
// slug has any identity-aware snapshot, its older legacy records are no
|
||||
// longer independently routable and must not resurface as zombie work.
|
||||
const targetIdentity = snapshotTargetIdentity(snapshot);
|
||||
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
|
||||
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
|
||||
latestByTarget.set(streamKey, snapshot);
|
||||
}
|
||||
return [...latestByTarget.values()]
|
||||
.filter((snapshot) => snapshot.meta.closed !== true)
|
||||
.sort((a, b) => a.path.localeCompare(b.path))
|
||||
.at(-1) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
||||
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
|
||||
// callers never have to run the slug step separately. Anything containing a
|
||||
// path or URL marker is resolved through slugFromTarget.
|
||||
function isReadySlug(value) {
|
||||
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
|
||||
}
|
||||
|
||||
function coerceSlug(value) {
|
||||
if (!value) return null;
|
||||
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
|
||||
if (isReadySlug(value)) return value;
|
||||
return slugFromTarget(value);
|
||||
}
|
||||
|
||||
@@ -181,14 +322,124 @@ function main(argv) {
|
||||
if (metaArg) {
|
||||
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
|
||||
}
|
||||
// The helper, not caller-provided metadata, owns the target fingerprint.
|
||||
// This makes the snapshot describe the exact file bytes critique saw.
|
||||
delete meta.target_fingerprint;
|
||||
delete meta.target_path;
|
||||
delete meta.target_identity;
|
||||
const targetIdentity = resolveTargetIdentity(slugArg);
|
||||
if (targetIdentity) meta.target_identity = targetIdentity;
|
||||
const targetFingerprint = fingerprintTarget(slugArg);
|
||||
if (targetFingerprint) {
|
||||
meta.target_fingerprint = targetFingerprint;
|
||||
meta.target_path = resolveLocalTargetPath(slugArg);
|
||||
}
|
||||
const out = writeSnapshot({ slug, meta, body: raw });
|
||||
process.stdout.write(`${out}\n`);
|
||||
return;
|
||||
}
|
||||
case 'latest': {
|
||||
const latest = readLatestSnapshot(coerceSlug(args[0]));
|
||||
if (!latest) { process.exit(2); }
|
||||
process.stdout.write(latest.body);
|
||||
const target = args[0];
|
||||
const format = args[1];
|
||||
const slug = coerceSlug(target);
|
||||
if (!slug || (format && format !== '--json')) {
|
||||
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const targetFingerprint = fingerprintTarget(target);
|
||||
const targetPath = resolveLocalTargetPath(target);
|
||||
const targetIdentity = resolveTargetIdentity(target);
|
||||
const readySlug = isReadySlug(target);
|
||||
const newestForSlug = readNewestSnapshot(slug);
|
||||
if (!newestForSlug) { process.exit(2); }
|
||||
|
||||
// Concrete targets select the newest snapshot for their exact identity,
|
||||
// not merely the newest filename for a lossy slug. This keeps distinct
|
||||
// targets such as foo/bar and foo-bar from hiding each other's backlog.
|
||||
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
|
||||
let latest = exactSnapshot;
|
||||
if (!latest && !readySlug) {
|
||||
// Legacy snapshots have no identity. Preserve their old explicit
|
||||
// path/URL behavior only when no known target identity was selected.
|
||||
latest = readNewestSnapshotForIdentity(slug, null);
|
||||
}
|
||||
if (!latest) latest = newestForSlug;
|
||||
if (latest.meta.closed === true) { process.exit(2); }
|
||||
|
||||
const recordedTargetPath = latest.meta.target_path;
|
||||
const recordedTargetIdentity = snapshotTargetIdentity(latest);
|
||||
const matchingIdentity = recordedTargetIdentity === targetIdentity;
|
||||
|
||||
// Bare slugs remain a supported lookup mode, including for URL
|
||||
// snapshots. But when a same-named local file exists, the request is
|
||||
// ambiguous unless that exact file owns the snapshot identity.
|
||||
if (readySlug && !recordedTargetIdentity) {
|
||||
process.stderr.write(
|
||||
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
|
||||
process.stderr.write(
|
||||
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const concreteTarget = !readySlug || matchingIdentity;
|
||||
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
|
||||
process.exit(2);
|
||||
}
|
||||
const concreteLocalTarget = concreteTarget && targetPath;
|
||||
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
|
||||
closeSnapshot(latest.path);
|
||||
process.exit(2);
|
||||
}
|
||||
if (format === '--json') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
snapshot_file: path.basename(latest.path),
|
||||
body: latest.body,
|
||||
}, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(latest.body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'close': {
|
||||
const [slugArg, snapshotFile, ...extra] = args;
|
||||
const slug = coerceSlug(slugArg);
|
||||
if (!slug || !snapshotFile || extra.length > 0) {
|
||||
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
if (
|
||||
path.basename(snapshotFile) !== snapshotFile
|
||||
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|
||||
|| !snapshotFile.endsWith(`__${slug}.md`)
|
||||
) process.exit(2);
|
||||
|
||||
// A slug and filename are not enough to prove ownership because two
|
||||
// distinct targets can normalize to the same slug. Modern snapshots
|
||||
// carry a canonical identity, so require the supplied resolved target
|
||||
// to match it before allowing the exact snapshot to be closed. Legacy
|
||||
// snapshots without identity retain their historical close behavior.
|
||||
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
|
||||
let snapshot;
|
||||
try {
|
||||
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
|
||||
snapshot = readSnapshot(snapshotPath);
|
||||
} catch {
|
||||
process.exit(2);
|
||||
}
|
||||
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
|
||||
if (
|
||||
recordedTargetIdentity
|
||||
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
|
||||
) process.exit(2);
|
||||
|
||||
const closed = closeSnapshot(snapshotFile);
|
||||
if (!closed) { process.exit(2); }
|
||||
process.stdout.write(`${closed}\n`);
|
||||
return;
|
||||
}
|
||||
case 'trend': {
|
||||
@@ -197,7 +448,7 @@ function main(argv) {
|
||||
return;
|
||||
}
|
||||
default:
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
|
||||
return String(value || '')
|
||||
.replace(/\s*\/\s*/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(token => token.trim())
|
||||
// var() fallbacks leave the closing parenthesis on the final token. Strip
|
||||
// it before length resolution so `8px)` is not treated as unitless 8rem.
|
||||
.map(token => token.trim().replace(/\)+$/, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,13 +70,27 @@ function isBrandFontOnOwnDomain(font) {
|
||||
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||
}
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
// Overused-font primary selection skips only CSS generics so a system stack
|
||||
// keeps the system face as primary; GENERIC_FONTS still includes platform
|
||||
// faces for design-system/serif resolution.
|
||||
const CSS_GENERIC_FONTS = new Set([
|
||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
...CSS_GENERIC_FONTS,
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
]);
|
||||
|
||||
function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
|
||||
return String(fontFamily || '')
|
||||
.split(',')
|
||||
.map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
|
||||
.find(f => f && !skip.has(f)) || null;
|
||||
}
|
||||
|
||||
// WCAG large text thresholds are defined in points: 18pt normal text and
|
||||
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
@@ -1591,7 +1605,7 @@ function checkIconTile(opts) {
|
||||
function resolveSerif(fontFamily) {
|
||||
if (!fontFamily) return { primary: null, isSerif: false };
|
||||
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
|
||||
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
|
||||
if (!primary) return { primary: null, isSerif: false };
|
||||
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
|
||||
if (tokens.includes('serif')) return { primary, isSerif: true };
|
||||
@@ -5190,8 +5204,7 @@ function checkTypography() {
|
||||
const style = getComputedStyle(el);
|
||||
const ff = style.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (!primary) continue;
|
||||
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
||||
totalTextElements++;
|
||||
@@ -5436,8 +5449,7 @@ function checkPageTypography(doc, win) {
|
||||
if (rule.type !== 1) continue;
|
||||
const ff = rule.style?.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
@@ -5456,11 +5468,10 @@ function checkPageTypography(doc, win) {
|
||||
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
let fm;
|
||||
while ((fm = ffRe.exec(html)) !== null) {
|
||||
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
||||
if (f && !GENERIC_FONTS.has(f)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
const primary = primaryFontFace(fm[1]);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { OVERUSED_FONTS, primaryFontFace } from '../../shared/constants.mjs';
|
||||
import {
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
@@ -51,9 +51,7 @@ function checkStaticPageTypography(document, window) {
|
||||
for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) {
|
||||
const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasText) continue;
|
||||
const ff = window.getComputedStyle(el).fontFamily || '';
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(window.getComputedStyle(el).fontFamily);
|
||||
if (!primary) continue;
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
isBrandFontOnOwnDomain,
|
||||
primaryFontFace,
|
||||
} from '../shared/constants.mjs';
|
||||
import {
|
||||
CSS_NAMED_COLORS,
|
||||
@@ -331,7 +332,7 @@ function checkIconTile(opts) {
|
||||
function resolveSerif(fontFamily) {
|
||||
if (!fontFamily) return { primary: null, isSerif: false };
|
||||
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
|
||||
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
|
||||
if (!primary) return { primary: null, isSerif: false };
|
||||
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
|
||||
if (tokens.includes('serif')) return { primary, isSerif: true };
|
||||
@@ -3930,8 +3931,7 @@ function checkTypography() {
|
||||
const style = getComputedStyle(el);
|
||||
const ff = style.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (!primary) continue;
|
||||
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
||||
totalTextElements++;
|
||||
@@ -4176,8 +4176,7 @@ function checkPageTypography(doc, win) {
|
||||
if (rule.type !== 1) continue;
|
||||
const ff = rule.style?.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
const primary = primaryFontFace(ff);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
@@ -4196,11 +4195,10 @@ function checkPageTypography(doc, win) {
|
||||
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
let fm;
|
||||
while ((fm = ffRe.exec(html)) !== null) {
|
||||
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
||||
if (f && !GENERIC_FONTS.has(f)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
const primary = primaryFontFace(fm[1]);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,13 +56,27 @@ function isBrandFontOnOwnDomain(font) {
|
||||
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||
}
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
// Overused-font primary selection skips only CSS generics so a system stack
|
||||
// keeps the system face as primary; GENERIC_FONTS still includes platform
|
||||
// faces for design-system/serif resolution.
|
||||
const CSS_GENERIC_FONTS = new Set([
|
||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
...CSS_GENERIC_FONTS,
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
]);
|
||||
|
||||
function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
|
||||
return String(fontFamily || '')
|
||||
.split(',')
|
||||
.map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
|
||||
.find(f => f && !skip.has(f)) || null;
|
||||
}
|
||||
|
||||
// WCAG large text thresholds are defined in points: 18pt normal text and
|
||||
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
@@ -104,6 +118,7 @@ export {
|
||||
BRAND_FONT_DOMAINS,
|
||||
isBrandFontOnOwnDomain,
|
||||
GENERIC_FONTS,
|
||||
primaryFontFace,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
|
||||
@@ -21,22 +21,24 @@ import zlib from 'node:zlib';
|
||||
const KEYWORD = 'impeccable:prompt';
|
||||
const args = process.argv.slice(2);
|
||||
const file = args.find(a => !a.startsWith('--'));
|
||||
const readMode = args.includes('--read');
|
||||
const scanMode = args.includes('--scan');
|
||||
const argOf = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : null; };
|
||||
|
||||
function promptOf(imagePath) {
|
||||
const b = fs.readFileSync(imagePath);
|
||||
let prompt = null;
|
||||
if (b.length > 8 && b.readUInt32BE(0) === 0x89504e47) prompt = readPngText(b);
|
||||
else if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8) prompt = readJpegCom(b);
|
||||
function imageType(buffer) {
|
||||
if (buffer.length > 8 && buffer.readUInt32BE(0) === 0x89504e47) return 'png';
|
||||
if (buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8) return 'jpeg';
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPrompt(imagePath, buffer = fs.readFileSync(imagePath)) {
|
||||
const type = imageType(buffer);
|
||||
let prompt = type === 'png' ? parsePng(buffer).prompt : type === 'jpeg' ? readJpegCom(buffer) : null;
|
||||
if (prompt == null && fs.existsSync(`${imagePath}.json`)) {
|
||||
try { prompt = JSON.parse(fs.readFileSync(`${imagePath}.json`, 'utf8')).prompt ?? null; } catch { /* stays null */ }
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
if (scanMode) {
|
||||
if (args.includes('--scan')) {
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
if (targets.length === 0) { console.error('embed-prompt: --scan needs at least one directory'); process.exit(1); }
|
||||
const RASTER = /\.(png|jpe?g|webp)$/i;
|
||||
@@ -59,7 +61,7 @@ if (scanMode) {
|
||||
}
|
||||
let missing = 0;
|
||||
for (const raster of rasters) {
|
||||
if (promptOf(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
|
||||
if (readPrompt(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
|
||||
}
|
||||
console.log(`SCAN: ${rasters.length} raster${rasters.length === 1 ? '' : 's'}, ${missing} missing`);
|
||||
process.exit(missing > 0 ? 3 : 0);
|
||||
@@ -68,8 +70,7 @@ if (scanMode) {
|
||||
if (!file || !fs.existsSync(file)) { console.error('embed-prompt: image file required'); process.exit(1); }
|
||||
|
||||
const buf = fs.readFileSync(file);
|
||||
const isPng = buf.length > 8 && buf.readUInt32BE(0) === 0x89504e47;
|
||||
const isJpeg = buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8;
|
||||
const type = imageType(buf);
|
||||
|
||||
const crcTable = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
@@ -87,22 +88,26 @@ function pngChunk(type, data) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function readPngText(b) {
|
||||
let off = 8;
|
||||
while (off + 12 <= b.length) {
|
||||
const len = b.readUInt32BE(off);
|
||||
const type = b.toString('ascii', off + 4, off + 8);
|
||||
if (type === 'tEXt' || type === 'zTXt') {
|
||||
const data = b.subarray(off + 8, off + 8 + len);
|
||||
const nul = data.indexOf(0);
|
||||
if (nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD) {
|
||||
if (type === 'tEXt') return data.toString('utf8', nul + 1);
|
||||
return zlib.inflateSync(data.subarray(nul + 2)).toString('utf8');
|
||||
}
|
||||
function parsePng(buffer) {
|
||||
const chunks = [];
|
||||
let prompt = null;
|
||||
let offset = 8;
|
||||
while (offset + 12 <= buffer.length) {
|
||||
const length = buffer.readUInt32BE(offset);
|
||||
const type = buffer.toString('ascii', offset + 4, offset + 8);
|
||||
const data = buffer.subarray(offset + 8, offset + 8 + length);
|
||||
const nul = data.indexOf(0);
|
||||
const promptChunk = (type === 'tEXt' || type === 'zTXt')
|
||||
&& nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
|
||||
if (prompt == null && promptChunk) {
|
||||
prompt = type === 'tEXt'
|
||||
? data.toString('utf8', nul + 1)
|
||||
: zlib.inflateSync(data.subarray(nul + 2)).toString('utf8');
|
||||
}
|
||||
off += 12 + len;
|
||||
chunks.push({ offset, type, promptChunk, bytes: buffer.subarray(offset, offset + 12 + length) });
|
||||
offset += 12 + length;
|
||||
}
|
||||
return null;
|
||||
return { chunks, prompt };
|
||||
}
|
||||
|
||||
function readJpegCom(b) {
|
||||
@@ -121,48 +126,34 @@ function readJpegCom(b) {
|
||||
}
|
||||
|
||||
const sidecar = `${file}.json`;
|
||||
if (readMode) {
|
||||
let prompt = null;
|
||||
if (isPng) prompt = readPngText(buf);
|
||||
else if (isJpeg) prompt = readJpegCom(buf);
|
||||
if (prompt == null && fs.existsSync(sidecar)) {
|
||||
try { prompt = JSON.parse(fs.readFileSync(sidecar, 'utf8')).prompt ?? null; } catch { /* fall through */ }
|
||||
}
|
||||
if (args.includes('--read')) {
|
||||
const prompt = readPrompt(file, buf);
|
||||
if (prompt == null) { console.error('embed-prompt: no embedded prompt found'); process.exit(2); }
|
||||
console.log(prompt);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const prompt = argOf('--prompt') ?? (argOf('--prompt-file') ? fs.readFileSync(argOf('--prompt-file'), 'utf8') : null);
|
||||
const promptFile = argOf('--prompt-file');
|
||||
const prompt = argOf('--prompt') ?? (promptFile ? fs.readFileSync(promptFile, 'utf8') : null);
|
||||
if (!prompt) { console.error('embed-prompt: --prompt or --prompt-file required'); process.exit(1); }
|
||||
|
||||
if (isPng) {
|
||||
if (type === 'png') {
|
||||
// Insert (or replace) our tEXt chunk immediately before IEND.
|
||||
const iend = buf.indexOf(Buffer.from('IEND', 'ascii')) - 4;
|
||||
const { chunks, prompt: existingPrompt } = parsePng(buf);
|
||||
const iend = chunks.find((chunk) => chunk.type === 'IEND')?.offset ?? -1;
|
||||
if (iend < 8) { console.error('embed-prompt: malformed PNG'); process.exit(1); }
|
||||
// Drop any existing chunk with our keyword to keep embedding idempotent.
|
||||
let body = buf.subarray(8, iend);
|
||||
const existing = readPngText(buf);
|
||||
if (existing != null) {
|
||||
const parts = [];
|
||||
let off = 8;
|
||||
while (off + 12 <= buf.length && off < iend + 12) {
|
||||
const len = buf.readUInt32BE(off);
|
||||
const type = buf.toString('ascii', off + 4, off + 8);
|
||||
const chunk = buf.subarray(off, off + 12 + len);
|
||||
const data = buf.subarray(off + 8, off + 8 + len);
|
||||
const nul = data.indexOf(0);
|
||||
const ours = (type === 'tEXt' || type === 'zTXt') && nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
|
||||
if (!ours && type !== 'IEND') parts.push(chunk);
|
||||
off += 12 + len;
|
||||
}
|
||||
body = Buffer.concat(parts).subarray(8 * 0); // parts exclude signature
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), pngChunk('IEND', Buffer.alloc(0))]));
|
||||
} else {
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, iend), pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), buf.subarray(iend)]));
|
||||
}
|
||||
const replacing = existingPrompt != null;
|
||||
const body = replacing
|
||||
? Buffer.concat(chunks
|
||||
.filter((chunk) => chunk.offset < iend && !chunk.promptChunk)
|
||||
.map((chunk) => chunk.bytes))
|
||||
: buf.subarray(8, iend);
|
||||
const promptChunk = pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')]));
|
||||
const end = replacing ? pngChunk('IEND', Buffer.alloc(0)) : buf.subarray(iend);
|
||||
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, promptChunk, end]));
|
||||
console.log(`EMBEDDED: ${file} (png tEXt, ${prompt.length} chars)`);
|
||||
} else if (isJpeg) {
|
||||
} else if (type === 'jpeg') {
|
||||
const seg = Buffer.from(`${KEYWORD}\0${prompt}`, 'utf8');
|
||||
if (seg.length + 2 > 0xffff) { console.error('embed-prompt: prompt too long for a JPEG segment'); process.exit(1); }
|
||||
const com = Buffer.alloc(4 + seg.length);
|
||||
|
||||
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
|
||||
// The prompt travels with the asset: embedded in the file itself (EXIF-class
|
||||
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
|
||||
// plus a sidecar for anything that indexes rather than opens the image.
|
||||
let embedded = false;
|
||||
try {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
embedded = !result.error && result.status === 0;
|
||||
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
|
||||
} catch { /* embedding is best-effort */ }
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
|
||||
if (plateCtx && plateCtx.chroma) {
|
||||
const frac = await keyChroma(out, plateCtx.chroma);
|
||||
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
|
||||
|
||||
@@ -770,9 +770,22 @@ function reset(cwd) {
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return removed.length
|
||||
? `Reset design hook config and cache (removed: ${removed.join(', ')}).`
|
||||
: 'No hook config or cache to remove. Already at defaults.';
|
||||
// `on` writes three things: config, consent, and hook entries in the
|
||||
// provider manifests. Reset must undo all three (issue #512): a leftover
|
||||
// manifest entry kept invoking the hook after the config that said "off"
|
||||
// was deleted. Local destRel only, since `on` never writes the team-shared
|
||||
// sharedDestRel. No skill-folder gate: a reset mid-uninstall (skill files
|
||||
// gone, manifest still wired) is the case that most needs the prune.
|
||||
const pruned = [];
|
||||
for (const target of HOOK_MANIFEST_TARGETS) {
|
||||
try {
|
||||
if (pruneImpeccableHookFromManifest(path.join(cwd, target.destRel))) pruned.push(target.provider);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const parts = [];
|
||||
if (removed.length) parts.push(`Reset design hook config and cache (removed: ${removed.join(', ')}).`);
|
||||
if (pruned.length) parts.push(`Removed hook entries from: ${pruned.join(', ')}.`);
|
||||
return parts.length ? parts.join(' ') : 'No hook config or cache to remove. Already at defaults.';
|
||||
}
|
||||
|
||||
function main() {
|
||||
|
||||
@@ -329,47 +329,37 @@ function stripBold(s) {
|
||||
function extractNamedRules(lines) {
|
||||
const rules = [];
|
||||
const seen = new Set();
|
||||
const addRule = (name, body, { allowDuplicate = false } = {}) => {
|
||||
const key = name.toLowerCase();
|
||||
if (!allowDuplicate && seen.has(key)) return;
|
||||
seen.add(key);
|
||||
rules.push({ name, body });
|
||||
};
|
||||
|
||||
// Style A (Impeccable): "**The X Rule.** body body body" — can span lines.
|
||||
const joined = lines.join('\n');
|
||||
const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g;
|
||||
const inlineMatches = [];
|
||||
let m;
|
||||
while ((m = inlineStart.exec(joined)) !== null) {
|
||||
inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex });
|
||||
}
|
||||
const inlineMatches = [...joined.matchAll(/\*\*(The [^*]+?Rule)\.\*\*/g)];
|
||||
for (let i = 0; i < inlineMatches.length; i++) {
|
||||
const mm = inlineMatches[i];
|
||||
const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;
|
||||
const match = inlineMatches[i];
|
||||
const bodyEnd = inlineMatches[i + 1]?.index ?? joined.length;
|
||||
const body = joined
|
||||
.slice(mm.end, bodyEnd)
|
||||
.slice(match.index + match[0].length, bodyEnd)
|
||||
.replace(/\n##[^\n]*$/s, '')
|
||||
.replace(/\n###[^\n]*$/s, '')
|
||||
.trim();
|
||||
const name = stripBold(mm.name).trim();
|
||||
seen.add(name.toLowerCase());
|
||||
rules.push({ name, body: stripBold(body) });
|
||||
// Preserve the inline format's historical behavior: repeated inline rules
|
||||
// remain visible, while the later heading and bullet formats dedupe.
|
||||
addRule(stripBold(match[1]).trim(), stripBold(body), { allowDuplicate: true });
|
||||
}
|
||||
|
||||
// Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the
|
||||
// bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const h3 = lines[i].match(/^###\s+(.+?)\s*$/);
|
||||
if (!h3) continue;
|
||||
const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim();
|
||||
for (const subsection of splitSubsections(lines).slice(1)) {
|
||||
const headerName = stripBold(subsection.name).replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;
|
||||
if (seen.has(headerName.toLowerCase())) continue;
|
||||
|
||||
const bodyLines = [];
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
if (/^##\s|^###\s/.test(lines[j])) break;
|
||||
bodyLines.push(lines[j]);
|
||||
}
|
||||
const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim();
|
||||
if (body) {
|
||||
seen.add(headerName.toLowerCase());
|
||||
rules.push({ name: headerName, body });
|
||||
}
|
||||
const body = stripBold(subsection.lines.join('\n').replace(/\n+/g, ' ')).trim();
|
||||
if (body) addRule(headerName, body);
|
||||
}
|
||||
|
||||
// Style C (Stitch bullet form): "* **The Layering Principle:** body"
|
||||
@@ -379,9 +369,7 @@ function extractNamedRules(lines) {
|
||||
if (!mm) continue;
|
||||
const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue;
|
||||
if (seen.has(nameRaw.toLowerCase())) continue;
|
||||
seen.add(nameRaw.toLowerCase());
|
||||
rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });
|
||||
addRule(nameRaw, stripBold(mm[2]).trim());
|
||||
}
|
||||
|
||||
return rules;
|
||||
|
||||
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
}),
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
||||
'Pragma': 'no-cache',
|
||||
});
|
||||
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
}
|
||||
if (p === '/detect.js' || p === '/') {
|
||||
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
|
||||
res.end(detectScript);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: PR Sheriff
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
schedule:
|
||||
# Daily UTC afternoon pass. The script uses an aggressive 7/14 day window:
|
||||
# warn contributor-blocked PRs after 7 days open, close after 14.
|
||||
@@ -24,7 +26,25 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
issue-inbox:
|
||||
if: github.event_name == 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mark issue for triage
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh label create "needs triage" \
|
||||
--repo "$GH_REPO" \
|
||||
--color "d4c5f9" \
|
||||
--description "New or reopened issue awaiting maintainer triage" \
|
||||
--force
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$GH_REPO" --add-label "needs triage"
|
||||
|
||||
sheriff:
|
||||
if: github.event_name != 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -49,4 +69,5 @@ jobs:
|
||||
--warning-days 7 \
|
||||
--close-days 14 \
|
||||
--maintainers "pbakaus" \
|
||||
--regular-contributors "pbakaus,abdulwahabone"
|
||||
--regular-contributors "pbakaus,abdulwahabone" \
|
||||
--auto-close-regulars
|
||||
|
||||
@@ -10,7 +10,7 @@ allowed-tools:
|
||||
- Bash(node .grok/skills/impeccable/scripts/*)
|
||||
---
|
||||
|
||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as a award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||
|
||||
Core principles:
|
||||
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
|
||||
@@ -20,7 +20,7 @@ Core principles:
|
||||
## Setup
|
||||
|
||||
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .grok/skills/impeccable/scripts/...` command in this skill and its references, and `.grok/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
|
||||
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
|
||||
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
|
||||
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
|
||||
|
||||
## How to design
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
### Purpose
|
||||
|
||||
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
|
||||
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
|
||||
|
||||
### Hard Invariants
|
||||
|
||||
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
|
||||
|
||||
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
|
||||
|
||||
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
|
||||
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
|
||||
|
||||
Structure your feedback as a design director would:
|
||||
|
||||
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
|
||||
node .grok/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
|
||||
```
|
||||
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
|
||||
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
|
||||
|
||||
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ The first argument is the action. Defaults to `status`.
|
||||
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
|
||||
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
|
||||
| `ignore-value <id> "*" --file <glob> [--file <glob>...]` | Turn one rule off in matching files only, leaving it active everywhere else. Repeat `--file`, or use `--file=<glob>` / `--files=<glob>`. A bare `"*"` with no `--file` is refused: use `ignore-rule <id>` if you really mean project-wide. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue, and remove the hook's entries from every provider manifest `on` installs, the committed Copilot file included (a team-shared `settings.json` that `on` never writes is never touched). |
|
||||
|
||||
## Flow
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
|
||||
4. Run `node .grok/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
|
||||
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .grok/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .grok/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
|
||||
|
||||
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
|
||||
@@ -68,16 +68,20 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su
|
||||
|
||||
## 5. Record the decision
|
||||
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
Before code, record the chosen direction as a development-only contract under `## Direction contract` in the relevant surface brief. A direction contract is durable route or artifact strategy, so create or update the brief even when no other surface strategy needs persistence. Keep the contract to six short blocks and 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The surface brief is the reminder later agents reload across edits and sessions: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
|
||||
Never copy the direction contract into implementation source or any browser-delivered artifact. This includes HTML or framework comments, hidden DOM, `<template>` elements, `data-*` attributes, rendered JSX or TSX output, serialized props or state, React Server Component payloads, client bundles, metadata or JSON-LD, accessibility-only text, and files served beside the artifact. A compiler or optimizer removing development metadata is not a safety boundary. Reviewers and documenters receive the contract from the surface brief.
|
||||
|
||||
On a new or replacement world, DESIGN.md is written at finish, from the built world, by the shipped documenter (section 7); a rulebook written before the build gets defended against reality instead of describing it, and hands the design-system detector an unstable target. A new world shipped with no DESIGN.md is still an incomplete run. An ordinary extension does not rewrite DESIGN.md.
|
||||
|
||||
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
|
||||
Read the existing surface brief before updating it:
|
||||
|
||||
`node .grok/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
|
||||
|
||||
`node .grok/skills/impeccable/scripts/surface-brief.mjs write <primary-target> <body-file> [related-target ...]`
|
||||
|
||||
After writing, read the brief once more and verify that all six contract blocks and the seed key are present before building.
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
On a comp-led build, whenever any image generation is available (a harness-native tool or the API fallback context.mjs reports), the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options put before the user for approval, the chosen card's decision comp plus two variations. This step is proven to produce the most compositional and ambitious work. On a code-led build the comp round is skipped by contract, never by drift: the ambition it would have carried lives in the direction contract's FIRST VIEWPORT block and named signature interaction, and the finish reviewer audits those promises in behavior.
|
||||
|
||||
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
|
||||
If a prior critique exists, use it as one input:
|
||||
|
||||
```bash
|
||||
node .grok/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
|
||||
node .grok/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
|
||||
```
|
||||
|
||||
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
|
||||
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
|
||||
|
||||
## 3. Triage
|
||||
|
||||
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
|
||||
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
|
||||
|
||||
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
|
||||
|
||||
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
|
||||
|
||||
```bash
|
||||
node .grok/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
|
||||
```
|
||||
|
||||
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
|
||||
|
||||
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
|
||||
|
||||
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
|
||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
approvedPoolRevision,
|
||||
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
|
||||
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
export function sameMainModulePath(left, right, platform = process.platform) {
|
||||
if (platform !== 'win32') return left === right;
|
||||
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
|
||||
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
|
||||
// invoked path. Compare real paths so a linked skill still runs its CLI,
|
||||
// normalizing the drive-letter casing that Windows junctions can change.
|
||||
return sameMainModulePath(
|
||||
realpathSync(process.argv[1]),
|
||||
realpathSync(fileURLToPath(import.meta.url))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const args = process.argv.slice(2);
|
||||
const fromIdx = args.indexOf('--from');
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* CLI entry points (called from skill instructions):
|
||||
* node critique-storage.mjs slug <resolved-target>
|
||||
* node critique-storage.mjs write <slug> <snapshot-body-file>
|
||||
* node critique-storage.mjs latest <slug>
|
||||
* node critique-storage.mjs latest <slug> [--json]
|
||||
* node critique-storage.mjs trend <slug> [limit]
|
||||
* node critique-storage.mjs close <resolved-target> <snapshot-file>
|
||||
*
|
||||
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
|
||||
* markdown file; the model reads it directly with its file-read tool. This
|
||||
@@ -27,6 +28,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
import { slugFromTarget } from './lib/target-slug.mjs';
|
||||
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
|
||||
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an exact content fingerprint for a local file target. URLs and
|
||||
* non-files return null because their content is not available here.
|
||||
*
|
||||
* The fingerprint deliberately describes bytes, not Git state or mtimes:
|
||||
* critique often assesses an uncommitted file, and a later polish run should
|
||||
* inherit that backlog when the bytes are unchanged regardless of staging.
|
||||
*/
|
||||
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
|
||||
if (!target || /^https?:\/\//i.test(target)) return null;
|
||||
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
|
||||
}
|
||||
|
||||
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
|
||||
if (!target || typeof target !== 'string') return null;
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
try {
|
||||
const url = new URL(target);
|
||||
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
||||
return `url:${url.origin}${pathname}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const filePath = resolveLocalTargetPath(target, { cwd });
|
||||
return filePath ? `file:${filePath}` : null;
|
||||
}
|
||||
|
||||
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
|
||||
const filePath = resolveLocalTargetPath(target, { cwd });
|
||||
if (!filePath) return null;
|
||||
try {
|
||||
if (!fs.statSync(filePath).isFile()) return null;
|
||||
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
|
||||
* keys read back by readTrend(). `body` is the human-readable critique
|
||||
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
|
||||
const dir = getCritiqueDir(cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const timestamp = nowFilenameStamp(now);
|
||||
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
|
||||
// Spread `meta` first so internally computed `timestamp` and `slug`
|
||||
// always win. Otherwise a caller-supplied meta blob (parsed from the
|
||||
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
|
||||
// filename in disagreement with its frontmatter and corrupting trends.
|
||||
const front = serializeFrontmatter({ ...meta, timestamp, slug });
|
||||
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
|
||||
return filePath;
|
||||
const contents = `${front}\n${body.trim()}\n`;
|
||||
|
||||
// A second critique can finish in the same UTC second. Use exclusive
|
||||
// creation and a fixed-width suffix so concurrent writers cannot replace
|
||||
// history and lexical ordering still keeps collision entries newest.
|
||||
for (let collision = 0; collision <= 9999; collision += 1) {
|
||||
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
|
||||
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
|
||||
try {
|
||||
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
|
||||
}
|
||||
|
||||
function serializeFrontmatter(obj) {
|
||||
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
|
||||
try { value = JSON.parse(value); } catch { /* leave as-is */ }
|
||||
} else if (/^-?\d+$/.test(value)) {
|
||||
value = Number(value);
|
||||
} else if (value === 'true' || value === 'false') {
|
||||
value = value === 'true';
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
|
||||
/**
|
||||
* Return snapshot files matching `suffix`, sorted oldest → newest.
|
||||
*/
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
|
||||
|
||||
function listSnapshots(suffix, cwd) {
|
||||
const dir = getCritiqueDir(cwd);
|
||||
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
|
||||
.map((f) => path.join(dir, f));
|
||||
}
|
||||
|
||||
function readLatestSnapshotMatching(suffix, cwd) {
|
||||
const filePath = listSnapshots(suffix, cwd).at(-1);
|
||||
function readSnapshot(filePath) {
|
||||
if (!filePath) return null;
|
||||
const body = fs.readFileSync(filePath, 'utf-8');
|
||||
return { path: filePath, body, meta: parseFrontmatter(body) };
|
||||
}
|
||||
|
||||
function snapshotTargetIdentity(snapshot) {
|
||||
const targetPath = snapshot?.meta.target_path;
|
||||
return snapshot?.meta.target_identity
|
||||
|| (targetPath ? `file:${targetPath}` : null);
|
||||
}
|
||||
|
||||
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
|
||||
}
|
||||
|
||||
function readNewestSnapshotForIdentity(
|
||||
slug,
|
||||
targetIdentity,
|
||||
{ cwd = process.cwd() } = {},
|
||||
) {
|
||||
const matches = listSnapshots(`__${slug}.md`, cwd)
|
||||
.map(readSnapshot)
|
||||
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
|
||||
return matches.at(-1) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the most recent snapshot for `slug`, or null. Polish reads this
|
||||
* to find its fix backlog when the slug matches.
|
||||
*/
|
||||
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
|
||||
const latest = readNewestSnapshot(slug, { cwd });
|
||||
return latest?.meta.closed === true ? null : latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one exact snapshot closed without deleting the score history consumed
|
||||
* by `trend`. Exact identity matters: a newer critique may land after polish
|
||||
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
|
||||
* may be the absolute path returned by readLatestSnapshot() or the basename
|
||||
* emitted by `latest --json`. Returns the path marked closed, or null.
|
||||
*/
|
||||
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
|
||||
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
|
||||
const dir = path.resolve(getCritiqueDir(cwd));
|
||||
const snapshotPath = path.isAbsolute(snapshotFile)
|
||||
? path.resolve(snapshotFile)
|
||||
: path.resolve(dir, snapshotFile);
|
||||
const filename = path.basename(snapshotPath);
|
||||
if (
|
||||
path.dirname(snapshotPath) !== dir
|
||||
|| !SNAPSHOT_FILENAME.test(filename)
|
||||
) return null;
|
||||
|
||||
let snapshot;
|
||||
try {
|
||||
if (!fs.lstatSync(snapshotPath).isFile()) return null;
|
||||
snapshot = readSnapshot(snapshotPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!snapshot || snapshot.meta.closed === true) return null;
|
||||
const closedBody = snapshot.body.replace(
|
||||
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
|
||||
'$1\nclosed: true$2',
|
||||
);
|
||||
if (closedBody === snapshot.body) {
|
||||
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
|
||||
}
|
||||
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
|
||||
return snapshot.path;
|
||||
}
|
||||
|
||||
/** Return the most recent snapshot across all targets, or null. */
|
||||
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
|
||||
return readLatestSnapshotMatching('.md', cwd);
|
||||
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
|
||||
const identifiedSlugs = new Set(
|
||||
snapshots
|
||||
.filter((snapshot) => snapshotTargetIdentity(snapshot))
|
||||
.map((snapshot) => snapshot.meta.slug),
|
||||
);
|
||||
const latestByTarget = new Map();
|
||||
for (const snapshot of snapshots) {
|
||||
if (!snapshot?.meta.slug) continue;
|
||||
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
|
||||
// one. Keep each known identity's latest open/closed state independent so
|
||||
// closing one target cannot hide another target's live backlog. Once a
|
||||
// slug has any identity-aware snapshot, its older legacy records are no
|
||||
// longer independently routable and must not resurface as zombie work.
|
||||
const targetIdentity = snapshotTargetIdentity(snapshot);
|
||||
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
|
||||
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
|
||||
latestByTarget.set(streamKey, snapshot);
|
||||
}
|
||||
return [...latestByTarget.values()]
|
||||
.filter((snapshot) => snapshot.meta.closed !== true)
|
||||
.sort((a, b) => a.path.localeCompare(b.path))
|
||||
.at(-1) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
|
||||
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
|
||||
// callers never have to run the slug step separately. Anything containing a
|
||||
// path or URL marker is resolved through slugFromTarget.
|
||||
function isReadySlug(value) {
|
||||
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
|
||||
}
|
||||
|
||||
function coerceSlug(value) {
|
||||
if (!value) return null;
|
||||
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
|
||||
if (isReadySlug(value)) return value;
|
||||
return slugFromTarget(value);
|
||||
}
|
||||
|
||||
@@ -181,14 +322,124 @@ function main(argv) {
|
||||
if (metaArg) {
|
||||
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
|
||||
}
|
||||
// The helper, not caller-provided metadata, owns the target fingerprint.
|
||||
// This makes the snapshot describe the exact file bytes critique saw.
|
||||
delete meta.target_fingerprint;
|
||||
delete meta.target_path;
|
||||
delete meta.target_identity;
|
||||
const targetIdentity = resolveTargetIdentity(slugArg);
|
||||
if (targetIdentity) meta.target_identity = targetIdentity;
|
||||
const targetFingerprint = fingerprintTarget(slugArg);
|
||||
if (targetFingerprint) {
|
||||
meta.target_fingerprint = targetFingerprint;
|
||||
meta.target_path = resolveLocalTargetPath(slugArg);
|
||||
}
|
||||
const out = writeSnapshot({ slug, meta, body: raw });
|
||||
process.stdout.write(`${out}\n`);
|
||||
return;
|
||||
}
|
||||
case 'latest': {
|
||||
const latest = readLatestSnapshot(coerceSlug(args[0]));
|
||||
if (!latest) { process.exit(2); }
|
||||
process.stdout.write(latest.body);
|
||||
const target = args[0];
|
||||
const format = args[1];
|
||||
const slug = coerceSlug(target);
|
||||
if (!slug || (format && format !== '--json')) {
|
||||
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const targetFingerprint = fingerprintTarget(target);
|
||||
const targetPath = resolveLocalTargetPath(target);
|
||||
const targetIdentity = resolveTargetIdentity(target);
|
||||
const readySlug = isReadySlug(target);
|
||||
const newestForSlug = readNewestSnapshot(slug);
|
||||
if (!newestForSlug) { process.exit(2); }
|
||||
|
||||
// Concrete targets select the newest snapshot for their exact identity,
|
||||
// not merely the newest filename for a lossy slug. This keeps distinct
|
||||
// targets such as foo/bar and foo-bar from hiding each other's backlog.
|
||||
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
|
||||
let latest = exactSnapshot;
|
||||
if (!latest && !readySlug) {
|
||||
// Legacy snapshots have no identity. Preserve their old explicit
|
||||
// path/URL behavior only when no known target identity was selected.
|
||||
latest = readNewestSnapshotForIdentity(slug, null);
|
||||
}
|
||||
if (!latest) latest = newestForSlug;
|
||||
if (latest.meta.closed === true) { process.exit(2); }
|
||||
|
||||
const recordedTargetPath = latest.meta.target_path;
|
||||
const recordedTargetIdentity = snapshotTargetIdentity(latest);
|
||||
const matchingIdentity = recordedTargetIdentity === targetIdentity;
|
||||
|
||||
// Bare slugs remain a supported lookup mode, including for URL
|
||||
// snapshots. But when a same-named local file exists, the request is
|
||||
// ambiguous unless that exact file owns the snapshot identity.
|
||||
if (readySlug && !recordedTargetIdentity) {
|
||||
process.stderr.write(
|
||||
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
|
||||
process.stderr.write(
|
||||
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const concreteTarget = !readySlug || matchingIdentity;
|
||||
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
|
||||
process.exit(2);
|
||||
}
|
||||
const concreteLocalTarget = concreteTarget && targetPath;
|
||||
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
|
||||
closeSnapshot(latest.path);
|
||||
process.exit(2);
|
||||
}
|
||||
if (format === '--json') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
snapshot_file: path.basename(latest.path),
|
||||
body: latest.body,
|
||||
}, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(latest.body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'close': {
|
||||
const [slugArg, snapshotFile, ...extra] = args;
|
||||
const slug = coerceSlug(slugArg);
|
||||
if (!slug || !snapshotFile || extra.length > 0) {
|
||||
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
if (
|
||||
path.basename(snapshotFile) !== snapshotFile
|
||||
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|
||||
|| !snapshotFile.endsWith(`__${slug}.md`)
|
||||
) process.exit(2);
|
||||
|
||||
// A slug and filename are not enough to prove ownership because two
|
||||
// distinct targets can normalize to the same slug. Modern snapshots
|
||||
// carry a canonical identity, so require the supplied resolved target
|
||||
// to match it before allowing the exact snapshot to be closed. Legacy
|
||||
// snapshots without identity retain their historical close behavior.
|
||||
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
|
||||
let snapshot;
|
||||
try {
|
||||
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
|
||||
snapshot = readSnapshot(snapshotPath);
|
||||
} catch {
|
||||
process.exit(2);
|
||||
}
|
||||
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
|
||||
if (
|
||||
recordedTargetIdentity
|
||||
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
|
||||
) process.exit(2);
|
||||
|
||||
const closed = closeSnapshot(snapshotFile);
|
||||
if (!closed) { process.exit(2); }
|
||||
process.stdout.write(`${closed}\n`);
|
||||
return;
|
||||
}
|
||||
case 'trend': {
|
||||
@@ -197,7 +448,7 @@ function main(argv) {
|
||||
return;
|
||||
}
|
||||
default:
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
|
||||
return String(value || '')
|
||||
.replace(/\s*\/\s*/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(token => token.trim())
|
||||
// var() fallbacks leave the closing parenthesis on the final token. Strip
|
||||
// it before length resolution so `8px)` is not treated as unitless 8rem.
|
||||
.map(token => token.trim().replace(/\)+$/, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user