Restore generated provider output to main's state
This PR stays source-first per 187e589e: the mid-rebase regeneration
swept in provider dirs the sync workflow owns, .veto included, which
main's CI produces after merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -8,7 +8,7 @@ allowed-tools:
|
||||
- Bash(node .agent/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 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.
|
||||
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.
|
||||
|
||||
Core principles:
|
||||
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
|
||||
@@ -18,7 +18,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 .agent/skills/impeccable/scripts/...` command in this skill and its references, and `.agent/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. 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.
|
||||
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
|
||||
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
|
||||
|
||||
## How to design
|
||||
@@ -47,7 +47,6 @@ Choose the mode from the requested surface, not the product, and persist it only
|
||||
| `init` | Build | Capture durable product context in PRODUCT.md | [reference/init.md](reference/init.md) |
|
||||
| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) |
|
||||
| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) |
|
||||
| `design-context [open/edit/export/import]` | Build | Reopen, revise, export, or import the design interview and its document | [reference/design-context.md](reference/design-context.md) |
|
||||
| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
|
||||
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) · native: [reference/audit.native.md](reference/audit.native.md) |
|
||||
| `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) |
|
||||
|
||||
@@ -99,7 +99,7 @@ For each issue, document:
|
||||
- **Impact**: How it affects users
|
||||
- **WCAG/Standard**: Which standard it violates (if applicable)
|
||||
- **Recommendation**: How to fix it
|
||||
- **Suggested command**: Which command to use (prefer: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable design-context, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
|
||||
- **Suggested command**: Which command to use (prefer: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
|
||||
|
||||
### Patterns & Systemic Issues
|
||||
|
||||
@@ -118,7 +118,7 @@ List recommended commands in priority order (P0 first, then P1, then P2):
|
||||
1. **[P?] `/command-name`**: Brief description (specific context from audit findings)
|
||||
2. **[P?] `/command-name`**: Brief description (specific context)
|
||||
|
||||
**Rules**: Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable design-context, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset. Map findings to the most appropriate command. End with `/impeccable polish` as the final step if any fixes were recommended.
|
||||
**Rules**: Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset. Map findings to the most appropriate command. End with `/impeccable polish` as the final step if any fixes were recommended.
|
||||
|
||||
After presenting the summary, tell the user:
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ For each issue, document:
|
||||
- **Impact**: How it affects users
|
||||
- **Guideline**: The HIG / Material rule it violates (if applicable)
|
||||
- **Recommendation**: How to fix it
|
||||
- **Suggested command**: Which command to use (prefer: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable design-context, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
|
||||
- **Suggested command**: Which command to use (prefer: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
|
||||
|
||||
### Patterns & Systemic Issues
|
||||
|
||||
@@ -121,7 +121,7 @@ List recommended commands in priority order (P0 first, then P1, then P2):
|
||||
1. **[P?] `/command-name`**: Brief description (specific context from audit findings)
|
||||
2. **[P?] `/command-name`**: Brief description (specific context)
|
||||
|
||||
**Rules**: Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable design-context, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset. Map findings to the most appropriate command. End with `/impeccable polish` as the final step if any fixes were recommended.
|
||||
**Rules**: Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset. Map findings to the most appropriate command. End with `/impeccable polish` as the final step if any fixes were recommended.
|
||||
|
||||
After presenting the summary, tell the user:
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
Load this after the direction is settled, and build without announcing the checklist. A pinned brief or the committed visual world overrides anything here; your own habit does not. When the design hook is active it already enforces the mechanical checks below as you edit: act on its findings instead of re-auditing each rule.
|
||||
|
||||
A wholesale file rewrite, after a hook block or an error recovery, starts by re-opening DESIGN.md and the token sheet, so the new file restates the recorded system rather than your memory of it; memory is where a display token becomes a hand-tuned clamp. The ranking lives in [new-work.md](new-work.md): the comp rules composition; the world rules material, and a rewrite changes neither.
|
||||
|
||||
## Verify
|
||||
|
||||
Each of these is a check on the built result, not an intention. Run them together in the batched inspection rounds, not as separate screenshot trips; the checks share one render.
|
||||
@@ -13,7 +11,6 @@ Each of these is a check on the built result, not an intention. Run them togethe
|
||||
- **Spacing:** tight groups, generous separation, more space above a heading than below it. Read the computed values.
|
||||
- **Type:** body measure 65–75ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
|
||||
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
|
||||
- **Tokens:** a size or color a step from a defined token takes the token: `0.82rem` beside a `0.8rem` token is the token, not a new size, and inline SVG or JS-drawn strokes take `var(--role)` or `currentColor`. Wire or remove what nothing consumes before finish; done means every defined custom property is consumed by at least one rule.
|
||||
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
|
||||
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
|
||||
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
|
||||
@@ -40,7 +37,7 @@ Surface habits:
|
||||
- Sparklines, progress rings, and soft-shadowed rounded rectangles standing in for content.
|
||||
- Monospace as a costume for "technical" rather than for code, data, or measurement.
|
||||
- A system display face (Impact, Arial Black, the platform sans) as the display voice of an own-world page. Source and self-host a face whose character matches the approved lettering; the closest installed font is a failure, not a fallback.
|
||||
- Unicode glyphs or emoji standing in for an icon system. Icons are drawn, from a real library or authored SVG, in one consistent stroke and weight. When a pack icon fails to fetch, take the nearest icon from the same pack, jsDelivr as the fallback CDN; drawing a replacement from scratch is an exception the user signs off on, so the one-pack promise survives a failed fetch.
|
||||
- Unicode glyphs or emoji standing in for an icon system. Icons are drawn, from a real library or authored SVG, in one consistent stroke and weight.
|
||||
- Geometric masks standing in for organic contours. A circle, polygon, or radial-gradient cutout approximating a photographic subject's edge is the cheap version of the effect and reads worse than omitting it. Derive an alpha matte from the actual image, or produce a cut-out asset.
|
||||
- Light or dark picked by category. Pick it from the use scene: who, where, under what ambient light.
|
||||
|
||||
|
||||
@@ -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 of that run.
|
||||
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.
|
||||
|
||||
### 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 an archive of that run.
|
||||
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.
|
||||
|
||||
Structure your feedback as a design director would:
|
||||
|
||||
@@ -142,7 +142,7 @@ For each issue, tag with **P0-P3 severity** (see [Issue Severity below](#issue-s
|
||||
- **[P?] What**: Name the problem clearly
|
||||
- **Why it matters**: How this hurts users or undermines goals
|
||||
- **Fix**: What to do about it (be concrete)
|
||||
- **Suggested command**: Which command could address this (from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable design-context, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
|
||||
- **Suggested command**: Which command could address this (from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
|
||||
|
||||
#### Persona Red Flags
|
||||
> *Consult the [Personas reference](#persona-based-design-testing) below.*
|
||||
@@ -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 .agent/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. 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.
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -257,7 +257,7 @@ List recommended commands in priority order, based on the user's answers:
|
||||
...
|
||||
|
||||
**Rules for recommendations**:
|
||||
- Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable design-context, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset
|
||||
- Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset
|
||||
- Order by the user's stated priorities first, then by impact
|
||||
- Each item's description should carry enough context that the command knows what to focus on
|
||||
- Map each Priority Issue to the appropriate command
|
||||
|
||||
@@ -11,27 +11,78 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh
|
||||
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields, PRODUCT.md, and on a questionnaire-seed world the packet's cue image with its **Cue, in words:** passage, nothing else; report a card too thin to brief a comp, never pad it from imagination. When the packet carries the cue, attach it to the generation call as a reference image and restate the passage's materials and light in the prompt in your own words; an attached image with no restated words leaves the model guessing what to take from it, and the cue's still-life composition never transfers, only its materials, palette, and light. A staged logo in the packet rides the same way: attach it and name it as the exact mark to reproduce, never a shape to reinterpret. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so this card is your entire contract; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; report a card too thin to brief a comp, never pad it from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world. A native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
Expect the measured spec (`.impeccable/build/spec.json`, written by `comp-spec.mjs` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on.
|
||||
Expect:
|
||||
|
||||
If there is no spec, stop and return one line asking the parent to run `comp-spec.mjs` first. You do not inventory the comp yourself; the spec is the inventory, and a second inventory disagrees with the first.
|
||||
- Approved mock path or screenshot reference.
|
||||
- Crop paths or a contact sheet with crop ids.
|
||||
- Output directory.
|
||||
- Required dimensions, format, transparency needs, and avoid list.
|
||||
- Notes on what should remain semantic HTML/CSS/SVG instead of raster.
|
||||
|
||||
## The job
|
||||
If the source mock is attached but has no filesystem path, use it for visual planning; ask for a path only before cropping or writing assets.
|
||||
|
||||
Every region with `medium: raster` in the spec ships as a plate at its `plate` path. A plate is the region regenerated at asset resolution from the comp crop as reference: same subject, same composition, same palette, same lighting and material, with the UI text and page chrome removed, at 1.5x the comp region's pixel size or more. The page draws text, controls, radius, shadow, and layout in code; the plate carries what code cannot draw. Crops from the comp are references, never shipping pixels: a comp is reference grade and a shipped crop is how a beautiful comp becomes a blurry site.
|
||||
Defaults unless contradicted:
|
||||
|
||||
Per region, in the spec's order:
|
||||
- `.webp` for opaque photos, backgrounds, and textures.
|
||||
- `.png` for transparent cutouts, seals, tickets, and illustrations.
|
||||
- Target production size, or at least 2x display size when dimensions are known. Never default to the small size of a full-page mock crop.
|
||||
- Remove UI text, navigation, buttons, labels, and body copy.
|
||||
- Keep physical marks only when the parent says they are part of the asset.
|
||||
- Remove letterboxing, empty padding, baked card corners, borders, shadows, caption bands, and layout background unless the parent says those pixels are intrinsic.
|
||||
- Keep the final assets directory clean: only files the build will consume. Source crops, reference crops, masks, and contact sheets go in a sibling `_sources`, `sources`, or review folder.
|
||||
|
||||
1. `node .agent/skills/impeccable/scripts/comp-spec.mjs --crop <id>` writes the reference crop under `.impeccable/build/crops/`.
|
||||
2. Produce the plate. With the API fallback: `node .agent/skills/impeccable/scripts/generate-image.mjs --plate <id> --quality high` does the whole step (crop as reference, the spec's plate prompt, output size chosen from the region's aspect, the file written to its plate path, prompt embedded, and the plate scored against the crop). With a harness-native image tool: use the crop as the input image and `node .agent/skills/impeccable/scripts/comp-spec.mjs --plate-prompt <id>` as the prompt, write the result to the plate path, then run `node .agent/skills/impeccable/scripts/embed-prompt.mjs <plate> --prompt "<the exact prompt>"`.
|
||||
3. Read the score line. `PLATE-SCORE` under 50%, or a `PLATE-WARN`, means the plate does not read as the region: open the plate beside the crop, name what drifted (subject, framing, palette, style), tighten the prompt with that, and regenerate once. Two misses on one region: keep the better plate, mark it `needs_parent_review`, and say why in one line.
|
||||
4. Transparent cutouts (a figure or object on the page ground): generate on a flat chroma color absent from the subject and key it to alpha before writing the PNG; never ship the keyed background.
|
||||
Ask blockers once, globally. Missing source path/crops or output directory blocks production. Exact dimensions, compression targets, retina variants, and format preferences do not; choose defaults and report them.
|
||||
|
||||
Do not redesign. Do not add objects, restyle, or reinterpret; the comp was approved as it is. Do not touch the page code, the spec, or the comp. Do not produce anything the spec does not list; a region the parent forgot goes back as a one-line note, not a plate.
|
||||
## Workflow
|
||||
|
||||
1. Inventory the full approved mock or every assigned crop.
|
||||
2. Put each visual role in exactly one bucket:
|
||||
- `produce`: needs generation, image editing, cleanup, cutout work, or a clean plate before it can ship.
|
||||
- `direct`: ships after format conversion, compression, or renaming because the parent supplied a real standalone source: a project file, stock, or prior production art. A crop from the approved mock is never `direct`, whatever its apparent size.
|
||||
- `semantic`: build in HTML/CSS/SVG/canvas, no raster output.
|
||||
3. Crops from the mock are binding visual references, never shipping pixels: a full-page mock's effective resolution is reference grade, and a shipped crop, however close it looks, is how a beautiful comp becomes a blurry site. Every mock-derived asset goes through `produce` as a clean regeneration.
|
||||
4. Give the parent an execution order for the `produce` bucket.
|
||||
5. For produced assets, choose the least inventive strategy: image-to-image clean plate, faithful regeneration from crop reference, transparent cutout, texture/pattern reconstruction, stock/project source, or a semantic HTML/CSS/SVG recommendation when raster is wrong.
|
||||
6. Use the harness's native image tool by default when generation or editing is needed; otherwise use the skill's generate-image.mjs.
|
||||
|
||||
7. Remove baked-in UI text, navigation, buttons, body copy, and mock chrome unless the text is part of the asset.
|
||||
8. Think through the final DOM/CSS representation before generating. If CSS will own radius, clipping, shadows, borders, perspective, responsive cropping, captions, or card frames, do not bake those into the bitmap.
|
||||
9. Save outputs non-destructively in the requested project directory, and leave the intent with the file: after every generation, run `node .agent/skills/impeccable/scripts/embed-prompt.mjs <asset> --prompt "<the prompt used>"` so the prompt lives inside the image itself. The build thread composes what you made and needs to know what it is looking at, and the embedding survives copies where sidecars get lost.
|
||||
10. Compare each output against its source crop, opening every image by its workspace-relative path; sandboxed viewers reject absolute paths. If a review/QA tool is available, run it before the final manifest, then retry each major/fatal finding once before finalizing.
|
||||
|
||||
Use `texture/pattern extraction` only when the source region is already clean enough to sample as texture. If UI, cards, labels, headings, body copy, or footer chrome must be removed first, classify it as crop-derived cleanup or clean-plate work.
|
||||
|
||||
Use `semantic` for dashboards, charts, controls, screenshots of whole UI sections, data widgets, card chrome, app frames, icon toolbars, logos, wordmarks, and anything the final implementation can render crisply in HTML/CSS/SVG/canvas. Ship a screenshot raster only when the parent explicitly says the screenshot itself is the final asset.
|
||||
|
||||
Semantic does not mean ignored. For every semantic role, write a concrete implementation handoff for the parent craft agent: the DOM/component layers, CSS-owned visual treatment, SVG/canvas/icon-library pieces, responsive behavior, and which nearby produced raster assets it composes with. For logos and icons, prefer inline SVG/vector or icon-library implementation unless the parent provides a production logo raster.
|
||||
|
||||
## Prompt Pattern
|
||||
|
||||
Use this shape for image-to-image work:
|
||||
|
||||
```text
|
||||
Use the provided crop as the approved visual reference.
|
||||
Recreate the same asset as a clean reusable production image at the target component aspect ratio and at least 2x display resolution.
|
||||
Preserve silhouette, object/scene perspective, camera angle, palette, lighting, material, texture, and visual role.
|
||||
Remove baked-in UI copy, navigation, buttons, labels, body text, watermarks, and mock chrome unless explicitly part of the asset.
|
||||
Remove letterboxing, padding, card borders, rounded clipping, CSS shadows, perspective transforms, caption bands, and layout backgrounds that the implementation should create in code.
|
||||
Do not add new objects. Do not change the concept. Do not redesign the composition.
|
||||
```
|
||||
|
||||
For transparent cutouts: use true alpha when the tool supports it; otherwise generate on a flat chroma-key color that cannot appear in the subject and post-process that color to alpha before shipping the PNG/WebP. Never ship the keyed background as the final asset.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Return one line per raster region: `<id> <plate path> <WxH> <score>% <accepted|needs_parent_review|blocked> <one-line note or ->`. Then `blockers` (missing spec, missing comp, no image capability, exhausted key) and `assumptions`, each global and minimal. Nothing else: no summary, no praise, no implementation advice. The parent runs `build-phase.mjs advance` to verify the plates against the same spec; your line and its line must agree.
|
||||
Return a complete manifest, grouped by `produce`, `direct`, and `semantic`. For each asset include: `id`, `source_crop`, `output_path` when applicable, `strategy`, `prompt_used` when applicable, `dimensions`, `format`, `transparency`, `deviations`, and `qa_status`.
|
||||
|
||||
For each semantic row include `id`, `implementation`, `notes`, and `qa_status`. The `implementation` is a concrete build handoff, not a note that no asset was produced: name the likely HTML/CSS/SVG/canvas/icon/component pieces and the visual responsibilities code owns.
|
||||
|
||||
`qa_status` is `accepted`, `needs_parent_review`, or `blocked`. `accepted` only after visual comparison passes. `needs_parent_review` for cut-off subjects, unwanted borders or rounded-card chrome, letterboxing, baked semantic text, low-resolution output, perspective that should have been CSS, missing transparency, or drift from the crop. `blocked` when inputs, permissions, image capability, or asset source quality prevent a credible result.
|
||||
|
||||
End with `execution_order`, `blockers`, and `assumptions` sections. Keep blockers global and minimal; per-asset rows carry only asset-specific risks or decisions.
|
||||
|
||||
Do not modify implementation code. Do not edit the approved mock. Do not produce final page copy. The parent craft agent owns implementation and final mock fidelity.
|
||||
@@ -11,16 +11,16 @@ A hard turn ceiling ends the run without warning; a run that ends before its con
|
||||
|
||||
## Input Contract
|
||||
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, in `.impeccable/review/` (web: `desktop.png` and `mobile.png`; native: device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive). A screenshot path the calling brief names is authoritative when the file exists; `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent. Also expect: the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); the PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths; on a comp-led build the approved comp path (a code-led build has none; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing here that binds "the approved comp" binds it); on a comp-led build the build state (`.impeccable/build/state.json`), the measured spec (`.impeccable/build/spec.json`), and the diff directories `.impeccable/review/diff/hero/` and `.impeccable/review/diff/final/` (each holds `side-by-side.png`, `heatmap.png`, `regions/<id>.png` paired crops, and `report.json` with per-region scores and verdicts from `comp-diff.mjs`); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet adds the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor, judge every check in the platform's own conventions, treat the screenshots as device captures, and know your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, in `.impeccable/review/` (web: `desktop.png` and `mobile.png`; native: device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive). A screenshot path the calling brief names is authoritative when the file exists; `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent. Also expect: the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); the PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths; on a comp-led build the approved comp path (a code-led build has none; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing here that binds "the approved comp" binds it); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet adds the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor, judge every check in the platform's own conventions, treat the screenshots as device captures, and know your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
|
||||
|
||||
## Checks, in order
|
||||
|
||||
0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-<width>.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round.
|
||||
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When a seed or prior DESIGN.md predates this build, it matches the built world, and "matches" is evidence you grep, not an impression: custom properties DESIGN.md defines that no rule consumes, literals sitting a step from a defined token, geometry a named rule bans (a 999px pill against a Slightly Soft rule). Each hit is a material finding under the craft floor's token rule, and an approved comp excuses none of them: the comp rules composition; the world rules material. On a new world with no seed, DESIGN.md is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
|
||||
2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
|
||||
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. A committed motion energy shows up as eased state changes in the shipped code; a recorded energy with zero transitions is a finding. The card governs commitment and finish, never composition.
|
||||
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/review/hero-repro.png` exists: the hero reproduction checkpoint's capture at the comp's own dimensions; its absence means the reproduction phase ran unproven, a material finding. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
|
||||
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
|
||||
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
|
||||
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport.
|
||||
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every raster region of the spec shipped as its plate (the spec names the file; the page references it; the region's diff row is not `missing`), not a gradient, an inline SVG, or a many-vertex `clip-path` standing in for it, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind a wash is a compliance token, not a shipped material, and the detector's `buried-raster` and `organic-clip-path` findings in the packet are material fixes.
|
||||
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
|
||||
6. **Floor.** Read the craft floor's Refuse list and hold the screenshots against it: kickers and eyebrows, hard offset shadows outside a neobrutalist world, glyph icons, system display faces, gradient text, side stripes, and the rest. A banned element is a material fix even when it matches nothing in the comp: the builder loaded the same ban before writing it, and fidelity to a comp cannot authorize what the floor refuses. The parent's hook findings cover this mechanically where hooks run; this check exists because hookless harnesses reach you with none, and the last two live sessions shipped five kickers past a reviewer that never looked.
|
||||
|
||||
Do not run a second detector pass; mechanical findings belong to the parent's hooks.
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# Design Context
|
||||
|
||||
Loaded by `/impeccable design-context`. Owns the design interview record, the document built from it, and its portable form. The interview itself is created by `/impeccable document` seed mode; this command is everything afterwards.
|
||||
|
||||
## Where it lives
|
||||
|
||||
One store, under the project root:
|
||||
|
||||
```text
|
||||
.impeccable/design-context/
|
||||
context.json the chat half of the interview: product, audience, brand, interview
|
||||
answers.json the questionnaire's decisions
|
||||
assets/ brand files the user supplied
|
||||
fonts/ font faces the user uploaded
|
||||
cue.png the chosen cue image, copied at submit
|
||||
runtime/ session.json, journal.jsonl, draft.json (local, gitignored)
|
||||
exports/ the written-out forms (local, gitignored)
|
||||
```
|
||||
|
||||
`.impeccable/visual-cues/` is separate on purpose: it is the generation workspace, regenerable and gitignored, and the document no longer depends on it. The store is the user's own record and is theirs to commit.
|
||||
|
||||
## No argument
|
||||
|
||||
Report status in two lines, then act:
|
||||
|
||||
- Whether `answers.json` exists, and when it was last written.
|
||||
- Whether a draft is waiting (`runtime/draft.json`), whether DESIGN.md is seeded, and whether a session is live (`runtime/session.json` naming a running process).
|
||||
|
||||
With answers on disk, do `open`. Without them, say the design context is created by the questionnaire and offer `/impeccable document`. Never start the questionnaire unasked.
|
||||
|
||||
## open
|
||||
|
||||
Reopen the document, live for edits.
|
||||
|
||||
Run `node .agent/skills/impeccable/scripts/picker-server.mjs --doc` from the project root as a foreground command and parse its `PICKER_URL` line. Open it and wait exactly as [visual-cues.md](visual-cues.md)'s launch paragraph does: its harness-browser ladder (in-IDE browser first, then another browser tool, then the system opener, then telling the user the URL) and its wait-on-the-foreground-process rule. Skip everything earlier in its Step 7: the cue announcement and the `modes` and `context` writes belong to a run that is generating cues, and this one is not.
|
||||
|
||||
Then enter the document edit loop below. The process exiting is the signal:
|
||||
|
||||
- `DOC_SESSION_ENDED` and exit 0: the document was closed. Say so in one line; the loop is over.
|
||||
- Exit 2: it timed out or was never opened. Say it can be reopened with the same command, and never relaunch unprompted.
|
||||
- Exit 1: no interview exists. Route to `/impeccable document`.
|
||||
|
||||
## edit
|
||||
|
||||
Re-run the questionnaire over the previous answers.
|
||||
|
||||
Say in one line what it will do before launching, and settle DESIGN.md in the same breath, because a new run replaces the seed the last one produced: *"This re-runs the questionnaire with your previous answers filled in. When you finish, I will refresh DESIGN.md from the new answers. Refresh it, overwrite it, or merge by hand?"* That is the whole consent for this run; do not ask again afterwards.
|
||||
|
||||
Then run `node .agent/skills/impeccable/scripts/picker-server.mjs`, using the same launch ladder and wait rule as `open`. Prefill happens on its own: an unfinished run resumes from its draft, a finished one loads its answers, and `--fresh` starts blank. Cues and `context.json` already exist from the previous run, so do not regenerate cues and do not repeat Step 7's pre-launch writes.
|
||||
|
||||
On exit 0, go to [document.md](document.md) Steps 5-6 and write the seed from the new `answers.json`, honoring the choice made before launch. On exit 2, nothing was answered and nothing changed.
|
||||
|
||||
If `.impeccable/visual-cues/cues.json` is missing, the questionnaire cannot run: its palette screen loads the dealt cues and the built-in seeds together and neither arrives without that file. Say so and offer a full `/impeccable document --seed` run instead.
|
||||
|
||||
## export
|
||||
|
||||
```text
|
||||
node .agent/skills/impeccable/scripts/design-context-export.mjs [--out DIR] [--no-assets]
|
||||
```
|
||||
|
||||
Writes two files and prints an `EXPORTED` line for each. Tell the user what each is for, in one line each:
|
||||
|
||||
- `design-context.md` is the design context as one readable document. It is what to hand another tool, another agent, or a collaborator who needs to follow this design.
|
||||
- `design-context.bundle.json` is the same context in a form `/impeccable design-context import` reads, including the files the user supplied.
|
||||
|
||||
Do not read the export back into the conversation; the user asked for a file, not a recitation.
|
||||
|
||||
## import
|
||||
|
||||
```text
|
||||
node .agent/skills/impeccable/scripts/design-context-import.mjs <bundle.json> [--design skip|write] [--force]
|
||||
```
|
||||
|
||||
It refuses a project that already has a design context unless `--force`, and refuses while a document is open either way. Report what it prints:
|
||||
|
||||
- `DESIGN_MD carried` with a DESIGN.md already here: ask whether to refresh it from the imported context, overwrite it, or merge by hand, then act.
|
||||
- `DESIGN_MD carried` with none here: offer to write it (`--design write`) or to re-seed from the imported answers through [document.md](document.md) Steps 5-6.
|
||||
- `DESIGN_MD absent`: say the bundle carried decisions but no design document, and offer to seed one.
|
||||
|
||||
Then offer `open`.
|
||||
|
||||
## The document edit loop
|
||||
|
||||
The document is a working surface. Follow [visual-cues.md](visual-cues.md)'s "The document edit loop" section; it is the canonical contract for polling, the event kinds, and the reply commands. Two things to hold on to while you are in it:
|
||||
|
||||
- **The session is the only writer of the store.** Never edit `answers.json` or `context.json` yourself while a session runs. Values you settle travel on your reply, through `--answers` or `--context`. DESIGN.md and PRODUCT.md are yours to write directly.
|
||||
- **A `save_batch` is already applied.** The user's values are in the store before you hear about them. Your work is the prose those values leave stale, in whichever document the event's `downstream` names.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Never poll `answers.json` while a server runs. The process exiting is the signal.
|
||||
- Never drive the questionnaire yourself. The answers are the user's, and a run you filled in is a run they did not make.
|
||||
- Editing in the document changes values that are already there. A field the interview never captured is added by asking in chat, not by this command.
|
||||
@@ -64,7 +64,6 @@ Omit irrelevant sections rather than filling them with invented rules. Put respo
|
||||
## When to run
|
||||
|
||||
- New-work found a coherent incumbent visual system but no `DESIGN.md`.
|
||||
- New-work paused before its direction roll on a project with no `DESIGN.md` and the user accepted the seed questionnaire recommendation; run seed mode.
|
||||
- The first implementation of a new world is complete and its provisional decisions need to be carbonized.
|
||||
- An existing `DESIGN.md` is stale (the design has drifted).
|
||||
- Before a large redesign, to capture the current state as a reference.
|
||||
@@ -74,9 +73,9 @@ If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user
|
||||
## Two paths
|
||||
|
||||
- **Scan mode** (default): the project has design tokens, components, or rendered output. Extract, then confirm descriptive language. Use when there's code to analyze.
|
||||
- **Seed mode**: the project is pre-implementation (fresh init, nothing built yet). Decide first whether the browser questionnaire can run, gather any existing brand assets, interview in chat (three named references and one anti-reference when the questionnaire will run; five high-level answers when it will not), then write a seed DESIGN.md marked `<!-- SEED -->` that carries every decision the interview and the questionnaire made. Re-run in scan mode once there's code.
|
||||
- **Seed mode**: the project is pre-implementation. Ensure PRODUCT.md exists, then reuse new-work's visual-world workshop and write its directional DESIGN.md seed. Re-run in scan mode once there's code.
|
||||
|
||||
Decide by scanning first (Scan mode Step 1). If the scan finds no tokens, no component files, and no rendered site, offer seed mode; don't silently switch. `/impeccable document --seed` forces seed mode on a pre-implementation project, but it does not authorize replacing coherent code: when an incumbent system exists, offer scan mode or route an explicit identity-replacement request through new-work.
|
||||
Decide by scanning first (Scan mode Step 1). If the scan finds no tokens, no component files, and no rendered site, offer seed mode; don't silently switch. `/impeccable document --seed` requests new-work's world workshop, but it does not authorize replacing coherent code: when an incumbent system exists, offer scan mode or route an explicit identity-replacement request through new-work.
|
||||
|
||||
## Scan mode (approach C: auto-extract, then confirm descriptive language)
|
||||
|
||||
@@ -309,7 +308,7 @@ The `html` and `css` fields must be **self-contained, drop-in snippets** that re
|
||||
|
||||
1. **Tailwind expansion.** If the source uses Tailwind (className="bg-primary text-white rounded-lg px-6 py-3"), expand every utility to literal CSS properties in the `css` string. Do **not** reference Tailwind classes; do **not** assume a Tailwind CSS bundle is loaded. Each component is self-contained.
|
||||
2. **Token resolution.** If the project exposes tokens as CSS custom properties on `:root` (e.g. `--color-primary`, `--radius-md`), reference them via `var(--color-primary)`; they inherit through the shadow DOM and stay live-bound. If tokens live only in JS theme objects (styled-components, CSS-in-JS), resolve to literal values at generation time.
|
||||
3. **Icons.** Inline as SVG. Do not reference Lucide/Heroicons packages, icon fonts, or `<img src="...">`. A typical icon is 16-24px; copy the SVG path data directly. This is about how a snippet ships, not about which family the project draws from: when the design names an icon set, keep using that set's glyphs and paste their path data in.
|
||||
3. **Icons.** Inline as SVG. Do not reference Lucide/Heroicons packages, icon fonts, or `<img src="...">`. A typical icon is 16-24px; copy the SVG path data directly.
|
||||
4. **States.** Include `:hover`, `:focus-visible`, and (if meaningful) `:active` rules inline. A static default-only snapshot makes the panel feel dead. Hover + focus rules in the CSS make it feel alive.
|
||||
5. **Reset bloat.** Extract only the component's *distinctive* CSS (background, color, padding, border-radius, typography, transition). Skip universal resets (`box-sizing: border-box`, `line-height: inherit`, `-webkit-font-smoothing`). The panel already has a neutral canvas; don't re-ship resets.
|
||||
6. **Scoped class names.** Prefix every class with `ds-` (e.g. `ds-btn-primary`, `ds-input-search`) so component CSS doesn't collide with other components' CSS in the same shadow DOM.
|
||||
@@ -350,138 +349,46 @@ Your own write is the freshest source; subsequent commands in this session don't
|
||||
|
||||
## Seed mode
|
||||
|
||||
For projects with no visual system to extract yet. Produces a minimal, user-chosen scaffold, not a fabricated token spec.
|
||||
For projects with no visual system to extract yet. Produces a user-chosen visual-world scaffold, not a fabricated token spec.
|
||||
|
||||
### Step 1: Route through new-work's workshop
|
||||
|
||||
PRODUCT.md is the prerequisite. If it is missing, load [init.md](init.md) and complete its product interview first. Do not create a visual identity without durable product context.
|
||||
|
||||
### Step 1: Decide the path, confirm seed mode, and ask for assets
|
||||
If PRODUCT.md exists, load [new-work.md](new-work.md) and resolve visual authority. Seed mode requires a concrete first surface: use the target the user named, or ask what they want to make first. Run new-work's **Create or replace the visual world** flow, then **Commit the world**, so the visual world and its first expression are chosen together. Stop after the directional DESIGN.md seed and surface brief; do not implement. A structured simulated user counts as the user and must get the same choice.
|
||||
|
||||
The browser questionnaire asks color strategy and motion per surface and picks concrete typefaces and a type scale by eye, so whether it will run decides what the chat interview may ask. Decide the path **before the first question**, never after the interview:
|
||||
If new-work already completed the workshop in this session, use its chosen direction directly. Do not ask again.
|
||||
|
||||
- **The harness has native image generation** (Codex's `image_gen`, an equivalent MCP tool, or similar): the questionnaire path; the cues are generated directly at Step 4, no setup needed. This branch wins even when `.impeccable/.env` already holds an `IMAGE_GEN_API_KEY` or an earlier run in another harness left a wrapper script behind; those are fallbacks for keyless harnesses, not the preferred path. A native tool that **cannot generate** (zero credits, failed auth) counts as absent: fall through to the next branch without asking, and mention the swap in the final report.
|
||||
- **No usable native path, key already in `.impeccable/.env`**: the questionnaire path, with no pause and no questions. Load [image-api.md](image-api.md) and use its shipped wrapper; it pre-answers everything this path has ever stopped to ask, including which provider the key belongs to.
|
||||
- **No usable native path, no key**: pause. Ask the user directly to clarify what you cannot infer. Ask whether the user wants generated visual cues to pick a palette by eye. *"I can generate a few small palette-and-mood images so you choose a direction visually instead of from descriptions. That needs an image-generation API key (FLUX and Google Nano Banana are supported out of the box; other providers work too), stored as `IMAGE_GEN_API_KEY` in `.impeccable/.env`. Add one, or skip straight to the chat interview?"* If a key arrives, write it to `.impeccable/.env` together with `IMAGE_GEN_PROVIDER` (`bfl` for FLUX, `gemini` for Nano Banana, the provider's own name for anything else; when the user does not say, let the wrapper infer it from the key). Confirm that file is listed in the project's `.gitignore` (add it if missing; a committed key is a leak), then load [image-api.md](image-api.md). Its shipped wrapper is the whole integration for the built-in providers; only a provider it does not know earns the project-local wrapper that file specifies. A key arriving makes this the questionnaire path.
|
||||
- **The user opts out, or no key arrives**: the interview-only path. The assets ask below, the five questions in Step 3, then Steps 5-6 from the interview alone.
|
||||
### Step 2: Write seed DESIGN.md
|
||||
|
||||
Then confirm seed mode and ask for assets, framed for the path:
|
||||
Use the canonical section order from Scan mode. Populate the selected workshop direction and leave unresolved implementation facts as honest placeholders. The seed commits a world and its invariants; it does not pretend implementation tokens already exist.
|
||||
|
||||
- **Questionnaire path**: *"There's no existing visual system to scan. You'll pick the visual direction by eye in a browser questionnaire; before I generate its options, three quick things. First: if you have any visual assets (a logo, reference or product images, moodboards), drop them in or point me at the files. They're extra context that makes the first DESIGN.md seed more accurate. You can re-run `/impeccable document` once there's code, to capture the real tokens and components. OK?"*
|
||||
- **Interview-only path**: *"There's no existing visual system to scan. I'll ask five quick questions to seed a starter DESIGN.md. First: if you have any visual assets (a logo, reference or product images, moodboards), drop them in or point me at the files. They'll ground the questions in what you already have. You can re-run `/impeccable document` once there's code, to capture the real tokens and components. OK?"*
|
||||
|
||||
Also glance for assets already in the project (`assets/`, `public/`, `brand/`, image files at the root); name anything found so the user can confirm it's relevant. Assets are optional: one ask, then proceed with whatever arrived.
|
||||
|
||||
If the user prefers to skip entirely, stop. No file.
|
||||
|
||||
### Step 2: Read the assets
|
||||
|
||||
Look at every asset provided (attached in chat or a file path) and record what it tells you, before writing the questions:
|
||||
|
||||
- **Logo**: sample the exact colors, note letterform character (geometric / humanist / serif) and temperature.
|
||||
- **Reference / product images**: density, palette, type feel; what the user is drawn to.
|
||||
- **Moodboards**: recurring hues, textures, era, register cues.
|
||||
|
||||
On the questionnaire path, the files themselves also feed the design context document the picker shows after the last question. When the user provided actual files (a logo, a mood board, a reference image), copy each one into `.impeccable/design-context/assets/`, keeping its filename. Record every staged file for Step 4's context write: it becomes an object entry in `context.json` `context.assets`, `{ "file": "<filename>", "kind": "logo" | "moodboard" | "reference", "note": "<one-line observation>" }`, where the note is what this step read off it. An observation with no file behind it stays a plain string entry, as before. On the interview-only path, stage nothing; the observations feed the questions and the seed alone.
|
||||
|
||||
These observations exist to sharpen Step 3. **No assets: skip straight to Step 3** with generic options.
|
||||
|
||||
### Step 3: The interview
|
||||
|
||||
Group each path's questions into one `AskUserQuestion` interaction. Options must be concrete. Keep skill vocabulary (seed, register, anti-reference) out of question text; ask for the thing in words the user would use. Ask like a magazine editor profiling the brand: curious and narrative, drawing out the feel the surface should carry.
|
||||
|
||||
**Questionnaire path: two questions, nothing more.** With Step 1's assets ask these are the whole chat interview; the questionnaire asks everything else by eye.
|
||||
|
||||
1. **Three named references.** Brands, products, printed objects. Not adjectives. When Step 2 produced observations, ground candidate names in them (references drawn from the moodboard's era).
|
||||
2. **One anti-reference.** What the product should NOT feel like. Also named.
|
||||
|
||||
**Do not ask about color, typography, or motion here; the questionnaire owns them.** It asks color strategy and motion per surface and picks concrete typefaces and a type scale, so a chat answer would be asked again by eye and one of the two would be thrown away. Both answered, go straight to Step 4.
|
||||
|
||||
**Interview-only path: five questions.** When Step 2 produced observations, ground the options in them: offer the logo's sampled color as a hue anchor in Q1, a type direction that matches the letterforms in Q2, candidate named references drawn from the moodboard's era in Q4. The user should recognize their own material in the choices.
|
||||
|
||||
1. **Color strategy.** Pick one:
|
||||
- Restrained: tinted neutrals + one accent ≤10%
|
||||
- Committed: one saturated color carries 30–60% of the surface
|
||||
- Full palette: 3–4 named color roles, each deliberate
|
||||
- Drenched: the surface IS the color
|
||||
|
||||
Then: one hue family or anchor reference ("deep teal", "mustard", "Klim #ff4500 orange").
|
||||
|
||||
2. **Typography direction.** Pick one (specific fonts come later):
|
||||
- Serif display + sans body
|
||||
- Single sans (warm / technical / geometric / humanist; pick a feel)
|
||||
- Display + mono
|
||||
- Mono-forward
|
||||
- Editorial script + sans
|
||||
|
||||
3. **Motion energy.** Pick one:
|
||||
- Restrained: state changes only
|
||||
- Responsive: feedback + transitions, no choreography
|
||||
- Choreographed: orchestrated entrances, scroll-driven sequences
|
||||
|
||||
4. **Three named references.** Brands, products, printed objects. Not adjectives.
|
||||
|
||||
5. **One anti-reference.** What it should NOT feel like. Also named.
|
||||
|
||||
### Step 4: Launch the questionnaire (questionnaire path only)
|
||||
|
||||
**Interview-only path: skip this step.** Go to Step 5 and seed from the answers alone. Step 1 already settled the capability question; do not re-open it here.
|
||||
|
||||
On the questionnaire path, **stop and load [visual-cues.md](visual-cues.md)** and follow its pipeline; it owns everything from the one-line user announcement and the persona palette studio through generation, `cues.json`, and the picker pause. Do not restate its mechanics here or in chat. The picker's exit is the handoff: when the server exits 0 and `.impeccable/design-context/answers.json` lands, come back here and run Steps 5-6 with that file in hand.
|
||||
|
||||
### Step 5: Write seed DESIGN.md
|
||||
|
||||
Use the canonical section order from Scan mode. Populate what the interview, the assets, and the questionnaire answer; leave the rest as honest placeholders. The seed is a scaffold, not a fabricated spec, but a decision the user actually made in the picker is real and belongs in the file at full strength.
|
||||
|
||||
Mark the file as a seed with this comment as the first line of the markdown body, immediately after the frontmatter's closing `---` (the frontmatter must open the file or token parsers will not see it):
|
||||
Lead the file with:
|
||||
|
||||
```markdown
|
||||
<!-- SEED: established with the user before implementation; re-run /impeccable document once there's code to capture the actual tokens and components. -->
|
||||
```
|
||||
|
||||
**Two seeds exist**, and which one you write depends on whether Step 4's picker ran:
|
||||
Per-section guidance in seed mode:
|
||||
|
||||
**Interview-only seed** (the user opted out of generation, or no key arrived). Per-section guidance:
|
||||
|
||||
- **Overview**: Creative North Star and philosophy phrased from the answers (color strategy + motion energy + references). Reference the user's anti-reference directly.
|
||||
- **Colors**: Color strategy as a Named Rule (e.g. *"The Drenched Rule. The surface IS the color."*). Hue family or anchor reference. Colors sampled from a provided logo are real; include them with exact values and note the source. Everything else stays `[to be resolved during implementation]`; those sampled anchors are the only hex this seed may carry.
|
||||
- **Typography**: the direction the user picked (e.g. "Serif display + sans body"). No font names yet: `[font pairing to be chosen at implementation]`.
|
||||
- **Layout** and **Shapes**: omit unless an asset or answer established a spatial or form preference; do not invent grids or corner language pre-implementation.
|
||||
- **Elevation & Depth**: inferred from motion energy. Restrained/Responsive → flat by default; Choreographed → layered. One sentence.
|
||||
- **Overview**: the chosen design thesis, layout behavior, material character, imagery stance, motion grammar, and reusable signature. Keep the selected first-surface expression in its surface brief; do not promote its composition into the global world.
|
||||
- **Colors**: the selected palette strategy and roles. Include values only when the user, an existing asset, or new-work's exploration established them; otherwise mark them `[to be resolved during implementation]`.
|
||||
- **Typography**: the selected type character and role relationship. Include font names only when established; otherwise mark the pairing `[to be resolved during implementation]`.
|
||||
- **Layout**: the selected spatial grammar and responsive behavior, without pretending exact measurements are settled.
|
||||
- **Elevation & Depth**: the selected material and depth behavior, stated as an invariant rather than inferred from a generic preset.
|
||||
- **Shapes**: the selected form and corner language.
|
||||
- **Components**: omit entirely; no components exist yet.
|
||||
- **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5.
|
||||
- **Do's and Don'ts**: record the durable guardrails confirmed during the world choice, not task-local refusals.
|
||||
|
||||
This seed writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet.
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
|
||||
**Questionnaire seed** (`.impeccable/design-context/answers.json` exists from this run). The user answered every screen by eye, so the seed carries their answers as decisions, not directions. **`_chosen` names the fields they actually set**: it holds a JSON-encoded array of per-surface keys, and a `<key>-<mode>` field missing from that array is a **preset** the picker minted when the surface was switched on, not an answer. Read the answers file plus the picked cue's palette entry in `.impeccable/visual-cues/cues.json` (`palette-source` names it), and map:
|
||||
|
||||
- **Frontmatter**: `name` and `description`, plus real `colors` (the four `palette-*` hex values under descriptive slugs; these are picked, not sampled) and real `typography` (`font-heading` and `font-body` are exact family names; give each role its family and weight intent, leave sizes for implementation). Derive the two text inks and record them under `colors` too: one near-black and one near-white, the pair the picker's previews already set their text in over these exact surfaces, each holding 4.5:1 against the grounds it will carry copy on, so a builder needing body-text contrast finds ink in the system instead of inventing a fifth color. Still no `rounded`, `spacing`, or `components`: the corner and spacing answers are qualitative, and nothing is built.
|
||||
- **Overview**: Creative North Star and philosophy phrased from the questionnaire's color-strategy and motion answers plus the chat references; reference the user's anti-reference directly. Name the chosen surfaces (`surface-modes`) and what each is for. Movement stays here, after the North Star, but the questionnaire asks it of a landing page and a portfolio only, so write what the keys support:
|
||||
- `motion-energy-<mode>` keys present, all agreeing: one philosophy sentence for the product, as before.
|
||||
- Keys present and disagreeing: one sentence per surface, named (*"The landing page moves on state change only; the portfolio stages entrances and drives sequences on scroll."*). The bare `motion-energy` is the leading one of the two.
|
||||
- **No `motion-energy` key at all**: the run has neither of those surfaces, so movement was never asked. Say nothing about it, and do not fill the gap from the register; this path's chat interview never asked about motion, so there is nothing to borrow. The next Scan-mode run reads the real transitions out of the code.
|
||||
- **Colors**: the four roles with their picked hex, noting the cue they came from. Name the chosen cue by its slug and name its kept image at `.impeccable/design-context/cue.png`, so a later build opens the picture the palette came from instead of imagining it; note that the unpicked cue images stay in `.impeccable/visual-cues/` for later art direction. Then open the kept image and describe it into the same section, three or four sentences under a **Cue, in words:** lead: the physical material each palette role lives on in the picture (cloth, glass, enamel, paper), the light and its temperature, the surface finish and grain, and the one material move that makes the image itself. Name each color as it appears on its material; `#C92823` as soft matte wrapping cloth instructs an image model where the bare hex only tints. Generation prompts on this world restate this passage (new-work.md and visualize.md say where), so a seed that records only the cue's file path leaves the material world to the model's imagination. The **chosen** strategy becomes the Named Rule. When surfaces differ (`color-strategy-<mode>` keys), state each surface's strategy and which surface leads (the bare key's owner).
|
||||
- **Typography**: the real pair by name, the pairing's character, and the type scale as a rule: `type-scale` names it, `type-scale-ratio` is the ratio (e.g. *"Major third: each heading step is 1.25x the last"*). Base size and exact steps stay `[resolved at implementation]`. A `font-heading-source` / `font-body-source` value means a user-provided font file; record where it lives.
|
||||
- **Layout**: `boundary-style` (how sections separate) per surface when the `-<mode>` keys differ, plus `layout-structure` (how pages are composed), which the questionnaire asks of a landing page and a portfolio only. No invented grids beyond what the answers state.
|
||||
- `layout-structure` present: one bare key and no `-<mode>` keys, so state it as a rule for the whole product rather than per surface.
|
||||
- **No `layout-structure` key at all**: the run has neither of those surfaces, so composition was never asked. Say nothing about how strict the grid is, and let `boundary-style` carry the section.
|
||||
- **Elevation & Depth**: `depth-style` per surface, stated directly; the questionnaire answered this, so do not re-infer it from motion energy.
|
||||
- **Shapes**: `corner-style` per surface.
|
||||
- **Components**: still omit; nothing exists yet.
|
||||
- **Do's and Don'ts**: the interview-only guidance, plus a Do fixing the icon source: every icon comes from the chosen pack (`icon-pack-name`, license, URL), no mixed sets. When the interview staged brand files (`context.assets` object entries in `.impeccable/design-context/context.json`), add one Do per file naming its path under `.impeccable/design-context/assets/`, its kind, and its note; a staged logo is the product's real mark and the build uses the file itself.
|
||||
|
||||
Per-surface answers come back for every chosen surface, presets included, and a difference between surfaces is a decision the picker enforced, not an inconsistency to smooth over (the option lists differ per surface, so a pick one surface allows can be unavailable on another and that surface falls to its preset). **Write a preset as provisional**, on the surface's own line: name the value, say it is that surface's default because the surface was never configured, and keep it out of the Named Rules and out of every product-wide sentence. Naming an untouched preset as a rule invents a law the user never chose. Where all surfaces agree **and `_chosen` shows the agreement was picked**, state the answer once for the product. `motion-energy` and `layout-structure` are the two keys that can be missing entirely, since movement and composition are asked of a landing page and a portfolio only; [visual-cues.md](visual-cues.md) has the full contract.
|
||||
|
||||
Both seeds skip the `.impeccable/design.json` sidecar: nothing to render yet. Real tokens for sizes, spacing, and components land on the next Scan-mode run.
|
||||
|
||||
### Step 6: Confirm
|
||||
### Step 3: Confirm
|
||||
|
||||
1. Show the seed DESIGN.md. Call out that it is a seed (the marker is the literal commitment).
|
||||
2. Tell the user: "Re-run `/impeccable document` once you have some code. That pass will extract real tokens and generate the sidecar."
|
||||
3. On the questionnaire path, add one line: the interview is kept, and `/impeccable design-context` reopens the document, re-runs the questionnaire over these answers, or writes the context out for another tool. See [design-context.md](design-context.md).
|
||||
|
||||
Your own write is the freshest source; no reload needed.
|
||||
|
||||
When the questionnaire ran, the confirm is not the end of the turn: the design context document in the user's tab is live for edits through the session the picker forked. Follow the document edit loop in [visual-cues.md](visual-cues.md): poll, apply `edit_request`s to this same DESIGN.md, reply. A color the user changed in the tab before your seed write is already in `answers.json`; one changed after arrives as a `save_batch` event, its value already in the store and its description in DESIGN.md yours to bring in line.
|
||||
|
||||
## Style guidelines
|
||||
|
||||
- **Frontmatter first, prose second.** Tokens go in the YAML frontmatter; prose contextualizes them. Don't redefine a token value in two places; the frontmatter is normative.
|
||||
|
||||
@@ -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, 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). |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
|
||||
|
||||
## Flow
|
||||
|
||||
@@ -102,7 +102,6 @@ node .agent/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Car
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- The design context document's Hooks page reads and writes this same config through `hook-admin.mjs` (`state` and `apply`, its machine channel, called by the doc session); those two verbs are not part of this command's routing. Entries it wrote are user decisions: the person pressed Apply in the page, so treat them like any user-made config.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Image API Path (keyless harnesses)
|
||||
|
||||
Loaded when a pipeline needs image generation and the harness has **no usable native tool**. It answers, upfront, every question an agent has historically stopped to ask on this path; with a funded key in place, a run through this file asks the user nothing and debugs nothing.
|
||||
|
||||
**This file never overrides a working native tool.** A harness with native image generation skips this path entirely; the precedence rule lives where the path is picked ([visual-cues.md](visual-cues.md) Step 3, [document.md](document.md) seed Step 1), not here. One refinement to that rule: a native tool that **cannot generate** (zero credits, failed auth, disabled account) counts as absent. Fall through to this path silently and mention the swap in the final report; do not stop to ask which path to use. A stopped question costs hours when the user is away; the swap costs nothing.
|
||||
|
||||
## The setup, already answered
|
||||
|
||||
- **Key**: `IMAGE_GEN_API_KEY` in `.impeccable/.env` at the project root. The wrapper reads that file itself; never `source` it, never export the key by hand, never rename the variable. Never delete or truncate that file either, cleanup included: it is the user's stored credential, not run output, and a wiped key turns the next run's silent keyless path into a stalled question.
|
||||
- **Provider**: `IMAGE_GEN_PROVIDER` in the same file: `bfl` (FLUX / Black Forest Labs) or `gemini` (Google Nano Banana), both built into the wrapper; any other value routes to a custom wrapper (below). Loose spellings from earlier runs (`flux`, `google`, `nano-banana`) normalize to the built-ins, and a missing provider line is inferred from the key's shape (Google keys start with `AIza` or `AQ.`; anything else runs as `bfl`), so a misworded or absent line is never a reason to stop and ask.
|
||||
- **Wrapper**: `.agent/skills/impeccable/scripts/image-gen.mjs`, shipped with the skill. Do **not** write a new wrapper for a built-in provider, edit this one, or fall back to raw `curl`/`fetch` calls; every known failure mode below is already handled inside it. Wrappers left by earlier runs under other names (`flux-gen.mjs`, project-local copies) are superseded by the shipped one.
|
||||
- **No smoke test.** A funded key plus the shipped wrapper is a working path; the first real generation is the test, and the wrapper turns transient failures into internal retries rather than failed calls.
|
||||
|
||||
## The command
|
||||
|
||||
One command regardless of provider; the provider switch happens inside the wrapper, so calling pipelines never branch on it:
|
||||
|
||||
```text
|
||||
node .agent/skills/impeccable/scripts/image-gen.mjs --prompt "..." --out /abs/path.png \
|
||||
[--ref /abs/reference.png] [--width 1408] [--height 1408]
|
||||
```
|
||||
|
||||
Run it from the project root (that is where it finds `.impeccable/.env`). It prints the absolute output path on success and exits non-zero with the error on stderr. `--ref` switches text-to-image to image-to-image where the provider supports it.
|
||||
|
||||
## Provider facts, so no one re-derives them
|
||||
|
||||
**bfl** (FLUX):
|
||||
|
||||
- **Models**: `flux-pro-1.1` text-to-image; with `--ref`, `flux-kontext-max` image-to-image (reference sent as base64, aspect ratio pinned 1:1).
|
||||
- **Size**: BFL accepts 256-1440 px in multiples of 32. The default `1408x1408` is the largest clean square; passing `--width 1500` fails validation locally, before any credit is spent. Output is always square unless you pass unequal values.
|
||||
- **Concurrency**: BFL allows 24 active tasks (`flux-kontext-max`: 6). A six-spawn wave fits both caps; do not throttle it.
|
||||
- **Protocol**: submit returns a `polling_url`; the wrapper polls exactly that URL (the global endpoint requires it) and downloads the signed result URL immediately, inside its 10-minute expiry. None of this is the caller's concern.
|
||||
|
||||
**gemini** (Nano Banana):
|
||||
|
||||
- **Model**: `gemini-3.1-flash-image` by default; a `IMAGE_GEN_MODEL` line in `.impeccable/.env` overrides it, and the wrapper retries the `-preview` sibling once when Google's model naming drifts.
|
||||
- **Size**: the wrapper pins aspect ratio 1:1, so output is always square; Gemini picks the pixel size for its tier (1024 by default) and ignores `--width`/`--height`. A 1024 square passes the pipelines' square gate as a "nearest supported square"; do not upscale it.
|
||||
- **Format**: Gemini frequently returns JPEG bytes regardless of the `--out` filename; the wrapper converts them, so the written file is always a real PNG. Do not re-check or re-convert it.
|
||||
- **Protocol**: synchronous; one call returns the image inline, no polling. Moderation arrives as an imageless response, which the wrapper turns into a clear error, not as an HTTP failure.
|
||||
- **Text rendering**: Gemini paints text well and eagerly, so a prompt that mentions codes, numbers, or labels tends to get them rendered onto the image (hex codes come back as a printed swatch strip). The calling pipeline's prompt rules ([visual-cues.md](visual-cues.md)'s HERO PROMPT skeleton) keep those out of prompts; follow them, not looser habits from other models.
|
||||
|
||||
**Any other provider**: the user names it, so the integration cannot be pre-shipped. Write `.impeccable/image-gen.mjs` implementing the same CLI (same flags, print the absolute output path on success, non-zero exit with the error on stderr, transient retries handled inside), set `IMAGE_GEN_PROVIDER` to the provider's name, and the shipped wrapper delegates to it automatically; calling pipelines keep using the shipped command unchanged. Build it from the provider's API docs, and give it square output; do **not** modify the shipped wrapper to add the provider inline.
|
||||
|
||||
## Failures and what they mean
|
||||
|
||||
The wrapper retries transient failures internally (DNS, network blips, 429 back-pressure, poll hiccups, expired-download re-fetches), so an error that reaches the caller is real and carries its own explanation:
|
||||
|
||||
- **"out of credits"** (bfl, HTTP 402): a human must top up at dashboard.bfl.ai. Report it and stop this path; retrying is pointless, and so is asking the user to choose an alternative that does not exist.
|
||||
- **"quota or rate limit exhausted"** (gemini, HTTP 429 after the wrapper's own retries): the key's plan is out of headroom. Report it and stop this path; the fix is billing, not retries.
|
||||
- **"rejected the key"** (either provider): the key in `.impeccable/.env` is wrong or revoked. Report it; do not mint debugging sessions around a dead key.
|
||||
- **Moderation** ("Content Moderated" / "Request Moderated" / "Prompt was moderated"): the prompt tripped the provider's filter; rewording the prompt is the fix, within the caller's normal generation budget.
|
||||
- **"cannot resolve"**: the wrapper already tried the system resolver, `dig`, Google, and Cloudflare. **Never debug DNS beyond this**: no `/etc/hosts` edits, no new resolvers, no rewriting the wrapper to use `fetch()` (sandboxed harnesses block the default resolver for these hosts; the wrapper pins IPs via `curl --resolve` for exactly that reason). Report the failure and let the parent decide.
|
||||
|
||||
Subagents on this path inherit the generation-failure budget from their own pipeline ([visual-cues.md](visual-cues.md)'s three-call budget, or the calling pipeline's equivalent); the wrapper's internal retries do not count against it, only whole failed invocations do.
|
||||
@@ -4,15 +4,13 @@ Use this flow for a new surface or a replacement visual identity. PRODUCT.md own
|
||||
|
||||
## 1. Decide what is already true
|
||||
|
||||
Read DESIGN.md, representative code, tokens, components, assets, and the interview record when Setup's `DESIGN_CONTEXT` directive reports one.
|
||||
Read DESIGN.md, representative code, tokens, components, and assets.
|
||||
|
||||
- **Redesign:** preserve product truth, content, function, constraints, and explicit brand commitments; replace the old visual world rather than polishing it. The old look is evidence of what the subject is, not authority over what it becomes.
|
||||
- **Established world:** inherit it. A missing DESIGN.md does not erase a coherent identity already in code; document that identity instead of inventing a replacement.
|
||||
- **Incomplete brand:** preserve confirmed assets and recognizable traits, then expand the system with the user for this surface.
|
||||
- **No visual authority:** create a new world with the user.
|
||||
|
||||
**A questionnaire seed is an established world with its evidence on disk.** When DESIGN.md carries the SEED marker and the interview record exists (`.impeccable/design-context/`), inherit the world and open the record before deciding anything: `cue.png` is the image the user picked the palette from, and each file under `assets/` is real brand material, its kind and note recorded in `context.json`. The record also settles most of section 2; ask only what it left open. Building on this world from DESIGN.md's words alone, with the cue unopened, is building from a paraphrase of a decision the user made in pixels.
|
||||
|
||||
A section, component, feature, or state inside an established surface inherits that surface. Never turn a local addition into a new identity exercise.
|
||||
|
||||
## 2. Ask what will change the work
|
||||
@@ -38,21 +36,19 @@ Keep the visual system fixed. Derive five to seven materially different structur
|
||||
|
||||
`node .agent/skills/impeccable/scripts/concept-seed.mjs --scope surface --mode <mode>`
|
||||
|
||||
The script deals three of your structures; the dice pick which three reach the user, breaking the ranking rut while the user keeps a real choice. Present them on the decision page as full cards of equal salience, the dealt lead under kicker THE ROLL, with steer and re-roll; the user locks one. No canon card and no pick card at surface scope: the world is settled, so every card visualizes composition, not identity. With image generation and a comp-led default (`.impeccable/config.json`; the build-path paragraph below), each card declares a `comp` under `.impeccable/mocks/decision/`, generated after serving, in reading order, under [visualize.md](visualize.md)'s comp discipline. Anchor each comp on the established identity: pass a screenshot of a representative existing page as a reference image (the harness image tool's input image, or `generate-image.mjs --ref`) with a prompt that leads with the new surface's structure and names DESIGN.md's palette, type, and component character; prose paraphrases of a design system drift, pixel references do not. On a seed world with no built page to screenshot, the chosen cue is that pixel reference: pass `.impeccable/design-context/cue.png` (and the staged logo when one exists) the same way. Pixels are half the anchor: restate the seed DESIGN.md's **Cue, in words:** passage in the prompt, the material each color lives on, the light, the finish, because the attached image sets a standard while the restated words tell the model which parts of the standard are the point. A seed DESIGN.md with no such passage (seeded before it was recorded) gets one written now: open the cue, write the passage into the Colors section under [document.md](document.md)'s shape, then prompt from it. The same cue rides the cards: on the comp-led roll every grounded card also declares the cue as its `hero`, the slot a catalog world's card art fills, so the page shows the world these compositions come from beside each comp and the lock's ANSWER names the image to open before code; challengers keep their own catalog inspirations. Without image generation, or under a code-led default, each card carries a `wireframe` schematic (`serve-question.mjs --schema`) the page draws itself. Locking a card is the approval and sets the build path: a locked comp builds comp-led with that comp as the approved comp, discharging [visualize.md](visualize.md)'s three-option round with no second approval point; a locked wireframe builds code-led, its ambition carried by the direction contract. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
||||
The script deals three of your structures; the dice pick which three reach the user, breaking the ranking rut while the user keeps a real choice. Present them on the decision page as full cards of equal salience, the dealt lead under kicker THE ROLL, with steer and re-roll; the user locks one. No canon card and no pick card at surface scope: the world is settled, so every card visualizes composition, not identity. With image generation and a comp-led default (`.impeccable/config.json`; the build-path paragraph below), each card declares a `comp` under `.impeccable/mocks/decision/`, generated after serving, in reading order, under [visualize.md](visualize.md)'s comp discipline. Anchor each comp on the established identity: pass a screenshot of a representative existing page as a reference image (the harness image tool's input image, or `generate-image.mjs --ref`) with a prompt that leads with the new surface's structure and names DESIGN.md's palette, type, and component character; prose paraphrases of a design system drift, pixel references do not. Without image generation, or under a code-led default, each card carries a `wireframe` schematic (`serve-question.mjs --schema`) the page draws itself. Locking a card is the approval and sets the build path: a locked comp builds comp-led with that comp as the approved comp, discharging [visualize.md](visualize.md)'s three-option round with no second approval point; a locked wireframe builds code-led, its ambition carried by the direction contract. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
||||
|
||||
### Create or replace the visual world
|
||||
|
||||
**When DESIGN.md is missing**, pause before any of the work below. Ask the user directly to clarify what you cannot infer. Ask once: recommend `/impeccable document --seed`, the guided interview plus browser questionnaire, because a world established from the user's own choices beats one assigned to them; offer the skip in the same breath. *"There's no design system on record yet. Before I invent directions for [the requested surface], I can run a short interview and browser questionnaire so the visual world is built from your choices; I recommend it. Or skip it and I'll roll a direction now."* The pause is enforced by the script, not by your discipline: with no DESIGN.md on record, the step 4 roll refuses to deal and prints this same offer until the questionnaire has been put to the user. Do not roll first and offer the questionnaire after, the assignment anchors the conversation; do not run document on the user's behalf without their yes. **Accepted:** seed mode in [document.md](document.md) owns the interview and the picker; follow it. When the seed DESIGN.md is written, resume at section 1: the seed world now reads as an established world, so inherit it; the direction roll below no longer applies, and the optional surface roll remains. Through every phase that follows, comp-led included, the comp rules composition; the world rules material, and this seed is that world. **Skipped:** re-run the step 4 command with `--seed-declined="<the user's verbatim skip answer>"`; the flag carries the user's own words as evidence of the skip, and the roll deals with nothing in step 4 softened. The build request itself is never a skip answer, a bare flag refuses again, and fabricating or paraphrasing the quote is a contract violation; only words the user typed after being asked qualify. When DESIGN.md exists, there is no pause and no flag.
|
||||
|
||||
1. Name the product's unique mechanism in one sentence, the audience's real scene, its cultural home, and what this first surface must prove. Note the page this category always ships and its predictable opposite; name both as the rut and keep them out of the seven-candidate list. A brief that paints its own picture, a product name, a titled artifact, a governing metaphor, adds its literal reading to the rut: spend at most one candidate on it and derive the rest from elsewhere in the audience's world.
|
||||
2. From that cultural world, list seven concrete visual systems, artifacts, places, or rituals the audience knows by heart, each with one line on why it resonates and can carry the mechanism, ordered by resonance. The audience's world includes its graphic and screen traditions, not only its physical objects: the notation, publications, identity programs, data graphics, and interfaces it reads daily; a nameable abstract system (a school of poster, a documentation standard) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; dig until the list spans at least three families.
|
||||
1. Name the product's unique mechanism in one sentence, the audience's real scene, its cultural home, and what this first surface must prove. Note the page this category always ships and its predictable opposite; both are the rut, kept out of the seven-candidate list. A brief that paints its own picture, a product name, a titled artifact, a governing metaphor, adds its literal reading to the rut: spend at most one candidate on it and derive the rest from elsewhere in the audience's world.
|
||||
2. From that cultural world, list seven concrete visual systems, artifacts, places, or rituals the audience knows by heart, each with one line on why it resonates and can carry the mechanism, ordered by resonance. The audience's world includes its graphic and screen traditions, not only its physical objects: the notation, publications, identity programs, data graphics, and interfaces it reads daily. A nameable abstract system (a school of poster, a documentation standard) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; dig until the list spans at least three families.
|
||||
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
|
||||
4. Run `node .agent/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. With no DESIGN.md the script refuses to deal until the seed pause above has run; after the user's explicit skip, and only then, re-run it with `--seed-declined="<their verbatim skip answer>"` carrying the user's own words. This step has no substitute and no skip condition (the seed pause above exits this subsection before any direction work starts; it is not a skip of the roll): on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point. The weighing closes with a verdict per challenger, decided before any borrowing is considered: wins (beats the assigned direction on both axes; it becomes the build candidate), competitive (holds one axis; it stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a motif lifted from a declined world is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
|
||||
4. Run `node .agent/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. 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 .agent/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. 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 .agent/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, the card's declared path, and on a questionnaire-seed world the cue image, the staged logo, and the seed DESIGN.md's **Cue, in words:** passage, 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.
|
||||
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.
|
||||
|
||||
The execution contract, comp-led or code-led, is a workflow preference, not a per-surface decision; no round asks it. The recorded default rides every round and the page's toggle handles the exception. Read the default from `.impeccable/config.json` (`buildPath`), the gitignored `.impeccable/config.local.json` winning where one machine differs from the team's committed value; with neither, comp-led is the default whenever image generation exists. Author every direction and surface payload with `buildPath: { "value": <default>, "toggle": true }`; the page renders a footer toggle with the trade stated beside it, and the ANSWER returns `buildPath` plus `buildPathFlipped`. A flipped value binds that session only and is never written back, with one exception, the only question this preference ever earns inside a round (init records it up front on projects that get the chance): when `buildPathFlipped` comes back true on a project that records no `buildPath` at all, ask once after the round closes whether to keep it as the standing default. Either answer writes `.impeccable/config.json`; the answer picks the value, never whether to record one. Yes writes the flipped value; "no, just this once" writes the value they flipped away from, the standing default they just confirmed by declining. Ask on the flip, never on the untouched default: a user who left the toggle alone told you nothing. A declined offer nothing writes down is an offer the next session makes again. When the user asks in words to change the standing default, update the file without asking. **Comp-led**: the chosen card's comp is law, generated before building when it does not yet exist, and the finish review audits the build against it; boldest composition on the table, fix rounds expected, and the comp is non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment. A code-led round still declares each card's comp path as a flip reserve: when the user flips the toggle to comp mid-round, `--wait` returns once with BUILD PATH FLIPPED while the page shimmers the slots; generate each open card's comp into its declared path then, lead first, and wait again. The flip back is free, and a comp that already rendered rides at the finish review as the critique reference. Without image generation there is no toggle and no choice: code-led is the only path, stated in one line rather than asked. The old two-card execution-contract round is retired; `followup: true` remains the general mechanism for delivering any later round over the same table via `--update`.
|
||||
|
||||
@@ -64,8 +60,6 @@ For **Persuade**, the opening must make the offer intelligible and desirable, ex
|
||||
|
||||
## 4. Commit the world
|
||||
|
||||
**A recorded world has already committed.** When DESIGN.md records these decisions, and a questionnaire seed always does, read them instead of choosing again: its strategy, faces, scale, and icon pack are the user's own picks and outrank this section's defaults. Per-surface records bind per surface: build the requested surface under its own mode's recorded strategy, boundaries, corners, depth, and motion; another surface's answer never substitutes, and the leading surface's bare answer is not a product-wide rule where the record splits. A surface whose mode the record never answered takes the world's product-wide rules plus the mode guidance, and you may offer `/impeccable design-context edit` once to re-run the questionnaire with that surface added; never block on it. The rest of this section is for worlds not yet recorded.
|
||||
|
||||
Pick a color strategy before picking colors: Restrained (neutrals plus one accent; the default when the visitor came to operate or read), Committed (one saturated color carries 30-60% of the surface), Full palette (3-4 named roles), or Drenched (the surface IS the color). Persuade and Experience surfaces have permission for the bolder strategies; take them when the brief allows. Color commits at page scale: fields that own whole regions, not accents scattered over a neutral ground. Dark or light is never a default: write one sentence of physical scene (who uses this, where, under what light) and let it force the answer.
|
||||
|
||||
Choose faces like objects from the subject's world, in the mode's register. Operate and Read surfaces are well served by system stacks and workhorse UI faces; Persuade and Experience surfaces want faces with a point of view, and these training-data defaults mean you stopped looking: Fraunces, Playfair Display, Cormorant, Lora, Crimson, Newsreader, Syne, Space Grotesk, Space Mono, IBM Plex, Inter-as-display, DM Sans, DM Serif, Outfit, Plus Jakarta Sans, Instrument Sans. Naming one of these faces anyway requires a reason no other face could satisfy, and a subject association is never that reason: books wanting a serif, bookshops wanting hand-lettering, and tech wanting a mono are the associations the list exists to break.
|
||||
@@ -74,20 +68,16 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su
|
||||
|
||||
## 5. Record the decision
|
||||
|
||||
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, the faces with the recorded type scale when DESIGN.md names one, and the 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Read the existing surface brief before updating it:
|
||||
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
|
||||
|
||||
`node .agent/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
|
||||
|
||||
`node .agent/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.
|
||||
@@ -96,55 +86,32 @@ For `shape`, return the selected direction to [shape.md](shape.md) and stop befo
|
||||
|
||||
## 6. Build with full commitment
|
||||
|
||||
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
|
||||
When an approved comp exists, the comp is king, and the build happens in phases. The comp is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words, and difficulty never infers a downgrade. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Every color the brief records gets that comparison by number, not by eye: sample the build screenshot's ground, dominant fields, and accents the same way each record was taken (an interior patch average where the record is an average, both end colors where the record is a gradient) and set each value against its recorded counterpart (sampled from the comp itself when the brief lacks one), and when a texture or tile paints over a base token, measure the net on-screen value, because the eye files a drifted color under the same color word and the number is what catches it. Judge the gap like a colorist, not a diff tool: a difference with a color name (warmer, grayer, darker than the record) is drift to fix, while a few digits of render and compression noise are the same color. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses.
|
||||
|
||||
**The comp rules composition; the world rules material.** Composition is the comp's dominion: topology, element inventory, density, region assignment. The recorded world's system stays binding through every phase: palette roles at their recorded scale of use, type tiers, corner language, motion register. Product truth in **PRODUCT.md** outranks both, so a comp never deletes a core product answer. A genuine conflict between the comp and a DESIGN.md named rule is resolved consciously and rides to the documenter's re-record at finish: adopt the deviation into the world's record, or conform the build.
|
||||
|
||||
### Comp-led: the comp is a measured contract
|
||||
|
||||
When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next:
|
||||
|
||||
`node .agent/skills/impeccable/scripts/build-phase.mjs start --direction <seed key> --kind <assigned|pick|challenger|canon>` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp <approved comp>` when a surface round already locked one.
|
||||
|
||||
Then, in order, each closed by `node .agent/skills/impeccable/scripts/build-phase.mjs advance` (every script below lives under `.agent/skills/impeccable/scripts/` and runs with `node`; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open):
|
||||
|
||||
0. **comps.** The comp round from [visualize.md](visualize.md): three compositional comps of the requested surface at its own viewport under `.impeccable/mocks/`, each with a prompt sidecar, put in front of the user; the chosen one's sidecar gets `"approved": true`. The gate counts them and reads the approval; a `start --comp` skips this phase because it already happened.
|
||||
The comp-led path is a frontier-tier job: it asks the builder to hold a measured layout, place plates at their boxes, and act on numeric readings across a dozen attempts. Smaller or faster models produce a recognisable page and stall under the hero gate; if the model in hand is one of those, say so before the direction round and take the code-led path, or expect the run to end at the hero with its readings unmet.
|
||||
|
||||
1. **spec.** Measure the comp: `comp-spec.mjs --comp <comp> --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `comp-spec.mjs --comp <comp> --regions <file>`. The spec carries each region's box, sampled palette, and medium; `comp-spec.mjs --print` is the build's reference from here on. Type is measured, not guessed: `font-match.mjs --measure <text region>` reads the comp's cap height, width class, and weight off the pixels, and `font-match.mjs --rank <region> --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors.
|
||||
2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (ink on flat ground is generated on a chroma key and keyed to alpha, so it sits on the page's own ground rather than a second paper); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `generate-image.mjs --plate <id>` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop <id>`) as its input image and `comp-spec.mjs --plate-prompt <id>` as its prompt, then `embed-prompt.mjs`. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason.
|
||||
3. **hero.** `build-phase.mjs scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r-<id>-x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an `<img>`, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `build-phase.mjs record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `comp-diff.mjs`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run.
|
||||
4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system.
|
||||
5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered.
|
||||
6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame.
|
||||
|
||||
### Code-led
|
||||
|
||||
No comp and no apology for it: the ambition lives in the direction contract's FIRST VIEWPORT block and the named signature interaction, and the finish reviewer audits those promises in behavior. The chosen decision comp rides to the finish review as the critique reference.
|
||||
|
||||
### Both paths
|
||||
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
|
||||
|
||||
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
|
||||
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
|
||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. Gradients, glass, generic icon tiles, and many-vertex `clip-path` polygons where an authored asset belongs are the gap wearing chrome; the detector flags the last two.
|
||||
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it.
|
||||
- **Prove the hero before building past it.** When an approved comp exists, render the first viewport, capture it at the comp's own pixel dimensions, and set it beside the comp's first viewport before any later section: the hero carries the run's ambition, and every following section inherits its shortfall. Save that capture as `.impeccable/review/hero-repro.png` (create the directory); the finish reviewer verifies it exists, so a skipped checkpoint is a visible checkpoint. Judge scale and density as quantities, a field at a tenth of the comp's coverage or type at half its weight is a different design, and a five-minute retry here is what a rebuild verdict at the finish costs when this check is skipped.
|
||||
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
|
||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
|
||||
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
|
||||
- **Pace the scroll like a studio.** Vary density, scale, image, motion, and quiet inside one grammar; a dense passage earns a quiet one, and the page ends anchored by a real close. One spacing rhythm throughout, with more space above a heading than below it.
|
||||
- **Use real, verified imagery when the brief implies it.** Search for the subject's physical object rather than the category; one decisive photo beats five mediocre ones. Verify stock URLs resolve.
|
||||
- **Author motion as material.** Give the page the form's native motion once, orchestrated, rather than scattered hover effects. Bound expensive effects and keep content visible by default.
|
||||
- **Author motion as material.** The form has native motion, what it does in life between states; give the page that motion once, orchestrated, rather than scattered hover effects. Bound expensive effects and keep content visible by default.
|
||||
|
||||
Preserve semantics, accessibility, performance, responsiveness, project conventions, and working behavior.
|
||||
|
||||
## 7. Inspect and finish
|
||||
|
||||
Inspect the surface's target sizes in one batched screenshot round: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes per OS, captured from the simulator or emulator the way the platform reference's Verifying the build section describes. When the harness reports the user's actual viewport (an in-app browser's size, a named resolution), add that width to the set: the width that breaks is the one the user sees first. Critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. On a comp-led build, run `node .agent/skills/impeccable/scripts/comp-diff.mjs --comp <approved comp> --build .impeccable/review/desktop.png --spec .impeccable/build/spec.json --out-dir .impeccable/review/diff/final` and read its region rows and paired crops as the critique: the side-by-side is the view the build thread never has on its own, and a region it scores missing or contradicted is a fix whatever the page looks like from memory. Never judge fidelity from one full-page thumbnail; it hides exactly the failures that matter. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
|
||||
Inspect the surface's target sizes in one batched screenshot round: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes per OS, captured from the simulator or emulator the way the platform reference's Verifying the build section describes. When the harness reports the user's actual viewport (an in-app browser's size, a named resolution), add that width to the set: the width that breaks is the one the user sees first. Critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
|
||||
|
||||
A capture is evidence only when it is valid, and you validate before you send. Settle or disable entrance motion first: an element hidden by animation timing reads as a missing element and gets fixed into a regression. Capture full-page shots from the document top. Capture the comp comparison at the comp's own pixel dimensions. Then open every file once and confirm it shows what its name claims: no black or blank regions, no wrong section behind a right filename, no half-loaded state. A malformed capture sent onward costs the whole round; the reviewer answers it with `disposition: recapture` and nothing it reviewed binds.
|
||||
|
||||
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. On the web, where this harness runs no design hook, run `node .agent/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless web build that skips this ships every tell the hook exists to catch. A native platform skips the detector entirely: it reads HTML and CSS and has no verdict on native code, so the reviewer's floor check is the only slop gate and the input packet says so. Capture the screenshots into `.impeccable/review/`, one file per captured viewport (on the web, `desktop.png` and `mobile.png`, plus `user-<width>.png` whenever the user's viewport joined the inspected set; on native, one per device class, such as `phone.png` and `tablet.png`, suffixed per OS on adaptive), creating that directory when the harness does not; the paths you pass the reviewer are its spec, every viewport you inspected is named required in the packet, and that directory is where it looks when a passed path is missing.
|
||||
|
||||
Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, the direction contract, existing hook findings, the QUALITY BAR card and approved comp paths (a code-led build has no approved comp; the chosen decision comp rides in that slot as the critique reference, named as such), on a comp-led build the build state (`.impeccable/build/state.json`), the spec, and the diff directories (`.impeccable/review/diff/hero/` and `.impeccable/review/diff/final/`, whose side-by-side, heatmap, region pairs, and `report.json` are the fidelity evidence), plus the chosen cue image (`.impeccable/design-context/cue.png`) on a questionnaire-seed world, named as the user's chosen cue, calibrating material and mood the way a QUALITY BAR card does, the craft-floor reference path, and on a native platform the platform reference path(s), [ios.md](ios.md) / [android.md](android.md), both on adaptive, plus one line saying no detector ran, so the reviewer judges in the platform's conventions rather than the web's. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify the return carries the five contract sections (a recapture return carries one, its recapture list); on an empty or thrashed return, respawn once with the same inputs. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness with no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently.
|
||||
Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, the direction contract, existing hook findings, the QUALITY BAR card and approved comp paths (a code-led build has no approved comp; the chosen decision comp rides in that slot as the critique reference, named as such), the craft-floor reference path, and on a native platform the platform reference path(s), [ios.md](ios.md) / [android.md](android.md), both on adaptive, plus one line saying no detector ran, so the reviewer judges in the platform's conventions rather than the web's. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify the return carries the five contract sections (a recapture return carries one, its recapture list); on an empty or thrashed return, respawn once with the same inputs. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness with no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently.
|
||||
|
||||
Act on the disposition word; there are exactly four. **recapture**: the evidence failed, not the build. Recapture what the return names under the capture-validity rules, then run a full review over the new evidence. A review conducted on invalid evidence binds nothing, and a verdict pass may never follow it. **rebuild**: fidelity failed wholesale, not in patches. Skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a fresh full review, never a verdict pass; a rebuild replaces regions wholesale, so the whole matrix runs again over the recaptures. Tell the user what is happening rather than asking permission to fix a failure. Consult the user only on a second rebuild directive, both verdicts on the table, or when rebuilding would discard content the user approved. **ship**: nothing is owed; report the verdict at its scope and continue to the documenter. **fix**: apply the material fixes in one batch, rebuild once, and recapture the same viewports over the same files. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round; the table states in one line that the documenter has not run and, when DESIGN.md is still the pre-build seed, names that too, so the stop reads as what it is, a paused run with an unrecorded world. Whoever decides, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Do not run a second detector.
|
||||
Act on the disposition word; there are exactly four. **recapture**: the evidence failed, not the build. Recapture what the return names under the capture-validity rules, then run a full review over the new evidence. A review conducted on invalid evidence binds nothing, and a verdict pass may never follow it. **rebuild**: fidelity failed wholesale, not in patches. Skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a fresh full review, never a verdict pass; a rebuild replaces regions wholesale, so the whole matrix runs again over the recaptures. Tell the user what is happening rather than asking permission to fix a failure. Consult the user only on a second rebuild directive, both verdicts on the table, or when rebuilding would discard content the user approved. **ship**: nothing is owed; report the verdict at its scope and continue to the documenter. **fix**: apply the material fixes in one batch, rebuild once, and recapture the same viewports over the same files. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever decides, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Do not run a second detector.
|
||||
|
||||
A rebuild and a fix round share one asset rule: a raster either round creates or replaces is still asset work under [visualize.md](visualize.md)'s Produce section and keeps its **provenance** like every build raster, and a raster the round abandons is deleted in the same batch. Before either round's result goes back for review or verdict, run `node .agent/skills/impeccable/scripts/embed-prompt.mjs --scan <asset-dir...>` over the directories the artifact's rasters ship from and clear every file it reports by embedding what it is missing: the exact generation prompt for a produced raster, the origin for a sourced, stock, or pre-existing one. The scan only reads; deletion is reserved for rasters the round abandoned, never for a file the scan flagged.
|
||||
|
||||
|
||||
@@ -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 .agent/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
|
||||
node .agent/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 3. Triage
|
||||
|
||||
@@ -95,11 +95,3 @@ 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 .agent/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 and closes it when stale or cleared).
|
||||
- `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.
|
||||
- `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`.
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
# Visual Cues Pipeline
|
||||
|
||||
Loaded by `/impeccable document` seed mode (Step 4) on the questionnaire path. Input: the seed interview's three named references and one anti-reference, the asset observations from seed Step 2, and PRODUCT.md. The chat interview asks no color, typography, or motion question on this path; the questionnaire and this pipeline own those decisions. Output: cue images plus `cues.json` under `.impeccable/visual-cues/`, ready for the user to pick from by eye in a later round.
|
||||
|
||||
Tell the user once, before starting: *"Generating visual cues; this can take a minute or two."* Then work without narration. Chat carries no per-image commentary, no palette tables, no prompt dumps; the folder is the deliverable.
|
||||
|
||||
## The image
|
||||
|
||||
Each cue is **one generation**: the hero.
|
||||
|
||||
```text
|
||||
HERO [slug].png (1500x1500)
|
||||
+---------------------------+
|
||||
| one close-framed scene, |
|
||||
| the product's world, |
|
||||
| four scene objects |
|
||||
| carrying the palette, |
|
||||
| every surface in frame |
|
||||
| one of the four colors, |
|
||||
| everything in crisp |
|
||||
| deep focus, no blur |
|
||||
+---------------------------+
|
||||
saved as-is, NO crop
|
||||
```
|
||||
|
||||
The **hero** is the visual cue: one close-framed scene from the product's world, its four objects carrying the palette as large color fields. This is what the user will pick between, so the colors get the real estate, and the frame has exactly two known thieves. **Blur**: an out-of-focus background is frame spent on mush, so everything renders in crisp, deep focus, front to back. **Undressed space**: every surface in frame is set-dressed to carry one of the four colors; the ground and backdrop belong to the neutral's material, and there is no bare wall, empty room, or whole person spending frame on colors nobody chose (hands mid-work belong to the scene; a face and outfit donate skin, hair, and clothing to the palette).
|
||||
|
||||
Example, hero = a flower atelier's worktable, framed close: unbleached linen spread as the ground and backdrop (neutral), a massed bank of wine-plum blooms in a ceramic vessel as the subject (primary), a band of dusty-rose petals beside it (secondary), one persimmon bloom set apart (tertiary), a florist's hands mid-arrangement, everything sharp.
|
||||
|
||||
## The studio
|
||||
|
||||
Palettes come from **six competing specialists**, not from you. One mind composing six palettes converges on one taste, and six versions of one mood defeat the pick round. Each specialist is a subagent locked to a **persona**: a different method of searching color space (object association, cultural reframing, remote analogy, self-imposed constraint, audience perspective-taking, emotional sequencing). Same brief, same output format, different search method; the separation is what makes the six palettes genuinely different.
|
||||
|
||||
The studio runs as **one parallel wave**. You carve six territories from the brief (Step 2), then all six personas spawn at once (Step 3), each composing a palette inside its own territory and staging it in its hero. No chained reviews, no revision loops: distinctness is settled upfront by the territory assignments, and speed comes from doing everything in one wave.
|
||||
|
||||
Subagents start without your context, so everything a specialist needs must reach it whole. The invariant material (brief packet, persona methods, territory map, craft rules, prompt skeleton, work steps) travels as one **brief file** every spawn reads; only the per-persona slots (persona number and name, territory line) ride in the spawn task itself. Copy shared blocks into the brief file **verbatim**; a summarized rule is a dropped rule, and retyping the full set into six long tasks costs minutes of pure prompt-typing per wave.
|
||||
|
||||
## Step 1: Assemble the brief packet
|
||||
|
||||
Write one self-contained text block that a specialist with zero context can design from. Include, in full:
|
||||
|
||||
- **The product**: from PRODUCT.md, what it is, sells, or shows; the audience; the positioning; the personality words.
|
||||
- **The interview**: the three named references and the anti-reference. State that the anti-reference is a hard constraint on every palette. There is no chat color strategy or hue anchor on this path; the territories (Step 2) own the color search.
|
||||
- **The assets**: the seed Step 2 observations (logo colors, recurring materials, photo moods).
|
||||
|
||||
Label it `BRIEF PACKET`; it goes into the brief file once (Step 3), so every specialist designs from the identical packet. Do **not** add your own palette leanings to it: the personas do the leaning.
|
||||
|
||||
## The six personas
|
||||
|
||||
The numbers only name the personas; Step 2 pairs each with a territory.
|
||||
|
||||
1. **The Ecological Naturalist**: derive every color from real materials, organisms, weather, or landscapes in the product's world. Name the physical source of each hex. No abstract "brand blue" thinking; the palette must feel materially plausible, textural, grounded.
|
||||
2. **The Cross-Cultural Anthropologist**: treat color as cultural meaning. Compare at least two cultural lenses relevant to this audience, find where the meanings align and where they diverge, and turn that tension into the palette. Do not stereotype or flatten into cliché.
|
||||
3. **The Analogy Hacker**: never start from the product category. Choose one distant domain (a jazz progression, a thermal camera, a medieval manuscript, a subway map, a laboratory stain chart) and translate its structure into color logic. The palette should never emerge from category convention, yet feel coherent once explained.
|
||||
4. **The Constraint Poet**: before composing, invent three to five severe but fruitful constraints ("one accent only", "every color must survive dusk", "mineral tones plus one synthetic intruder"), then compose the strongest palette inside them. Do not relax the rules; tension is the point.
|
||||
5. **The Audience Empath**: design from the audience's exact emotional and cognitive state at their first critical encounter with the product: what they need to feel, notice, and trust in that moment. The brand's ego does not vote.
|
||||
6. **The Emotion Dramaturge**: build the palette as an emotional arc, not a static board. Define the felt sequence of using this product (invitation, curiosity, tension, confidence, release) and assign hue, lightness, and saturation to its beats.
|
||||
|
||||
Accessibility and implementation stay **out** of the personas: the PALETTE RULES block carries the contrast requirements for everyone. A persona whose identity is "the contrast checker" composes cautious mud.
|
||||
|
||||
## Shared blocks
|
||||
|
||||
These go into the brief file (Step 3) in the order its assembly list names. They are the single source of the craft rules; never restate them loosely.
|
||||
|
||||
### PALETTE RULES
|
||||
|
||||
```text
|
||||
Compose exactly four hex values with a 60-30-10 balance. These are
|
||||
website/app colors, headed for design tokens, not scene colors:
|
||||
|
||||
- neutral (~60%, the dominant): the surface, what most of a screen will
|
||||
be. An off-white or near-white with a temperature tint, at least as
|
||||
pale as #ECEAE6: a mid-tone neutral that reads fine as a scene material
|
||||
turns into a gray slab once it is a screen background. Near-black is
|
||||
the one alternative, when the mood calls for dark; there is no
|
||||
in-between. Never pure #FFFFFF or #000000.
|
||||
- primary (~30%): the brand color, the mood's main carrier. Must read
|
||||
clearly against the neutral.
|
||||
- secondary: structure and support: an adjacent hue, or the primary
|
||||
shifted in lightness and chroma. Visibly a different swatch, not a
|
||||
darker copy of primary.
|
||||
- tertiary (~10%, the accent): the most saturated of the four and used
|
||||
smallest; distinct in hue from primary so it keeps signal value.
|
||||
|
||||
Hard rules:
|
||||
- Write one mood phrase specific enough to compose from. Good: "dawn
|
||||
delivery run, cut stems in cold water, the city still gray". Bad:
|
||||
"modern and clean"; a phrase that fits any brand composes nothing.
|
||||
- Every color earns its place: for each role, one line on what it does
|
||||
and why it fits this product. A color you cannot justify in one line
|
||||
gets replaced, not kept because it looks nice.
|
||||
- Contrast is non-negotiable: primary must read clearly on the neutral;
|
||||
tertiary must pop against both. A palette that fails either is not done.
|
||||
- Any two roles must be nameable apart at a glance. A dark green primary
|
||||
next to a dark green neutral is one color, not two.
|
||||
- The brief's anti-reference is a hard constraint.
|
||||
```
|
||||
|
||||
### CONCEPT RULES
|
||||
|
||||
```text
|
||||
Attach one one-line cue concept to the palette; the hero image stages it.
|
||||
|
||||
- The concept lives in the product's own world, named with the brief's
|
||||
own nouns. A concept that could belong to any other product is not
|
||||
done; sharpen it until it could only be this brand.
|
||||
- Give it a material world (botanical, ceramic, paper, textile, metal,
|
||||
glass, stone, food) as the supporting cast around the product's
|
||||
subject, never a replacement for it.
|
||||
- Name four scene objects, the palette's physical carriers, each passing
|
||||
three tests: it lives inside the scene, so it plausibly sits in the
|
||||
hero composition; it can carry its color as one large unbroken field
|
||||
at close framing (a massed bank of blooms, a draped cloth, a glazed
|
||||
vessel; a single bud or a thin ribbon cannot, and a color whose
|
||||
carrier is one small object ships as an unjudgeable sliver); and it
|
||||
is plain and unprinted (no tags, labels, packaging, printed cards,
|
||||
or stationery), because text on an object ruins the cue.
|
||||
- Name the concept with a two-word slug (amber-dusk, coastal-glass).
|
||||
```
|
||||
|
||||
### HERO PROMPT skeleton
|
||||
|
||||
Written like screenplay direction, not a keyword list: subject doing something, in a place, in a light. The scene stays the product's world; the palette's real estate is won inside it, by set dressing and by focus, never by deleting the scene. **Never ask for shallow depth of field, bokeh, or a soft background**: an out-of-focus stretch of frame is real estate spent on mush, so the prompt demands crisp, deep focus front to back. And every surface in frame is dressed to carry one of the four colors: the ground and backdrop belong to the neutral's material, and no bare wall, empty room, or whole person appears (hands mid-work belong to the scene; a face and outfit donate skin, hair, and clothing to the frame).
|
||||
|
||||
Name every color in plain language only, as a rich material description ("deep wine-plum, the color of reduced port"), tied to its carrier. **Never put a hex code, or any number, in an image prompt**: image models that render text well will paint it onto the image as a label or a swatch strip, and even one stray numeral fails the wordless check below. The hexes already travel in the PALETTE report line, and the compile step snaps them to rendered pixels; the prompt's job is the color's look, not its code. For the same reason, say what fills the frame instead of listing what to omit; a bare "no text" line is the weakest form of the instruction and the wordless sentence below is the strong form. Keep both.
|
||||
|
||||
Light the scene to reveal color, not to set a mood. In a dim, dusky, or nocturnal rendering every color sinks into one warm-brown murk the user cannot sample from, so bright, generous light is a hard rule even when the concept's moment is dark: an "after hours" or "dawn" concept keeps its props and story but is lit like a studio still, not like the hour. Dark palettes are welcome; dark renderings are not; a near-black primary should read as a rich, clearly-lit surface, not as underexposure.
|
||||
|
||||
The neutral's ground pays the highest price for shading. The compile step snaps each role to the pixels the hero actually rendered, and the picker shows the snapped value, so a nominally off-white linen that renders in mid-gray shadow ships a mid-gray surface color to the user. Describe the neutral's material as pale in the prompt ("pale unbleached linen, near-white in even light") and keep its field lit edge to edge, so the rendered ground stays as pale as the composed hex. Fill every `[bracketed]` slot; never leave template language in the prompt.
|
||||
|
||||
```text
|
||||
One full-bleed photograph, square format, framed close: [one scene from
|
||||
the product's world: subject and what it is doing, setting], the subject
|
||||
filling most of the frame, not a wide view of the room. The scene
|
||||
contains [object A], [object B], [object C], and [object D], all plainly
|
||||
visible. The scene is art-directed as bold color blocking in a strict
|
||||
four-color story: every surface in frame carries one of the four colors,
|
||||
each color one large unbroken field, none reduced to a sliver, no
|
||||
stretch of frame left to a color outside the four: [the neutral's
|
||||
carrier], [plain-language color with a material-world comparison], as
|
||||
the ground and backdrop, about half the frame, evenly lit edge to edge
|
||||
with no shadow gradient across it; [the primary's carrier],
|
||||
[color description], one continuous mass over roughly a third of the
|
||||
frame, carried by the main subject; [the secondary's carrier], [color
|
||||
description], a clear supporting field beside it; [the tertiary's
|
||||
carrier], [color description], one small vivid accent, big enough to
|
||||
read at a glance. Focus: deep and even, every object and surface in
|
||||
crisp sharp focus from front to back; no blur, no bokeh, no soft
|
||||
out-of-focus background anywhere in the frame. Camera: [tight still-life
|
||||
framing and angle, e.g. "straight-on still life at table height" or
|
||||
"high overhead of the worktable"]. Lighting: bright, even, generous
|
||||
studio daylight; every color fully lit, true, and saturated, no area
|
||||
lost to shadow. Mood: [two or three adjectives from the brief's
|
||||
personality]. The image is completely wordless: every material is plain
|
||||
and unprinted, a world with no lettering, numerals, tags, labels, or
|
||||
graphics anywhere in it. Rich, saturated, editorial color; not a dim,
|
||||
dusky, nocturnal, or candlelit image. Photorealistic, real texture. No
|
||||
text, no watermark.
|
||||
```
|
||||
|
||||
## Step 2: Carve the territories
|
||||
|
||||
Split the brief's color space into six **territories**, one per persona. Each is a one-line claim with two halves: a scene ground (a mood, a moment, a positioning angle) and, always, a **hue ground** it closes on (a named hue register). A hue-silent territory does not constrain color: give six specialists scenic territories and one shared brief, and every one of them will resolve to the product's one obvious hue; the hue ground is what makes the palettes diverge, the scene ground is what makes the stories diverge. Example set for a florist: "the delivery run before the city wakes: cold blue-teal dawn", "the atelier after hours: lacquer near-black with amber", "the potting bench: warm terracotta and unbleached paper", "gallery restraint: paper-white with one ink accent", "market-stall abundance: saturated market greens", "the drying room: muted botanical earth and rose".
|
||||
|
||||
Hard rules:
|
||||
|
||||
- **No two hue grounds share a hue family.** Six registers, six families.
|
||||
- **A hue anchor exists only when an asset fixes one** (a logo's sampled color, a recurring moodboard hue from the seed Step 2 observations). When one exists it belongs to exactly one territory (two only when the brief argues for it). Name its owner; Step 3 tells everyone else the anchor is off-limits. An anchor left unassigned is an anchor every persona obeys. No asset anchor: no owner, and the map's anchor sentence is dropped.
|
||||
- **A territory claims colors, not lighting.** "Lacquer near-black with amber" means those hues, staged in bright, clear light like every other palette; the HERO PROMPT skeleton forbids dim renderings, and a dark-moment territory ("after hours", "dawn") does not override it.
|
||||
- The anti-reference rules all six.
|
||||
|
||||
Assign each territory to the persona whose method suits it best (the Naturalist takes the most material ground, the Dramaturge the most emotional, the Empath the one closest to the audience's state).
|
||||
|
||||
Done when: six one-line territories exist, each closing on a hue ground, no two hue grounds in one family, any asset-fixed anchor owned by exactly one, each assigned to a persona.
|
||||
|
||||
## Step 3: The wave (parallel)
|
||||
|
||||
**Pick the generation path first.** The harness's native image-generation tool is the path whenever one exists and works; a native tool that **cannot generate** (zero credits, failed auth) counts as absent: fall through without asking the user, and mention the swap in the final report. The keyless path is [image-api.md](image-api.md): its shipped wrapper and pre-answered setup are canonical, so a key in `.impeccable/.env` or a leftover project-local wrapper never outranks a working native tool, and never needs re-deriving when it is the path.
|
||||
|
||||
**Do not smoke-test the path.** Presence is the whole check: a tool the harness lists works, and the image-api.md wrapper already retries transient failures internally, so a preflight generation buys nothing the first persona's report would not carry, and it costs a generation call and half a minute on every clean run. Instead, fill the brief file's tool slot with the exact call the spawns will make: the tool or wrapper command, the square-size parameter to pass, where it writes output files (some native tools ignore directory paths and save to a fixed folder of their own; say so in the slot, so no specialist rediscovers it alone), and whether the output is already guaranteed square (the shipped wrapper's is), so no specialist burns a tool call measuring it.
|
||||
|
||||
If the harness exposes any subagent/spawn tool (Task, spawn_agent, agents, or similar), parallel is **required**, not preferred: emit all six spawns as **one tool-call batch, a single message carrying six spawn calls**, one persona per subagent, each doing the full job (palette, concept, hero), and only then wait for the reports. Spawning one, waiting for its report, then spawning the next is a serial loop and a failure even though every spawn "used a subagent"; so is generating any image yourself while a subagent tool exists. The whole run must take only as long as the slowest single persona. Attach the harness's image-generation skill to each spawn when the harness expects that (Codex: the `imagegen` skill). (No subagent tool at all: Step 4.)
|
||||
|
||||
### The brief file
|
||||
|
||||
Write `.impeccable/visual-cues/brief.md` once, before spawning: the SPECIALIST BRIEF body below with its tool slot filled, then, appended in this order, the BRIEF PACKET, **The six personas** list verbatim from this document, the TERRITORIES block from Step 2's carve, then PALETTE RULES, CONCEPT RULES, and the HERO PROMPT skeleton with its framing paragraphs, verbatim from this document. One byte-exact file read by all six replaces six retyped copies of the same several-thousand-word block: the spawn tasks stay a few lines long, the wave starts in seconds instead of minutes, and retries reread the identical rules. **If the harness's subagents cannot read files**, paste the brief file's full contents into each task instead; the file stays the single source either way.
|
||||
|
||||
The TERRITORIES block is the wave's off-limits map, written once here instead of five off-limits lines retyped into every spawn:
|
||||
|
||||
```text
|
||||
TERRITORIES (your task names your row; every other row is off-limits)
|
||||
1. [persona name]: [territory line]
|
||||
2. [persona name]: [territory line]
|
||||
3. [persona name]: [territory line]
|
||||
4. [persona name]: [territory line]
|
||||
5. [persona name]: [territory line]
|
||||
6. [persona name]: [territory line]
|
||||
The hue anchor ([the asset-fixed anchor]) belongs to row [N] alone. If
|
||||
that row is yours, carry it; otherwise your primary must live in a
|
||||
different hue family.
|
||||
```
|
||||
|
||||
The block's closing anchor sentence appears only when Step 2 named an asset-fixed anchor and its owner; with no anchor, end the block after row 6.
|
||||
|
||||
SPECIALIST BRIEF body:
|
||||
|
||||
```text
|
||||
You are a color specialist. You compose one brand palette inside an
|
||||
assigned territory, then stage it in one hero image. Your spawn task
|
||||
names your persona and your territory; this file carries everything
|
||||
else: the brief packet, your persona's method, the territory map, the
|
||||
craft rules, the prompt skeleton, and the steps below.
|
||||
|
||||
This file is your only read. Do not open PRODUCT.md, DESIGN.md, or any
|
||||
other repo file: the BRIEF PACKET already carries everything they would
|
||||
tell you, and every extra read costs the wave time.
|
||||
|
||||
Generate the image with [the exact tool or command for the chosen
|
||||
generation path, the square-size parameter to pass, and where it
|
||||
writes output files]. Use only that; do not edit repo files.
|
||||
|
||||
The hero gets a hard budget of three generation calls, all reasons
|
||||
combined (failed calls, timeouts, and the retry checks below). A call
|
||||
that fails with a network, API, or timeout error may be re-run as-is
|
||||
within that budget; when the budget is spent, stop and report per step
|
||||
5. The failure is the parent's problem, not yours: never debug DNS or
|
||||
connectivity, never install packages, and never edit or rewrite the
|
||||
generation tooling.
|
||||
|
||||
1. Compose your palette, in your persona's method, inside your
|
||||
territory, following the PALETTE RULES section below.
|
||||
|
||||
2. Draft the concept for the palette, following the CONCEPT RULES
|
||||
section below.
|
||||
|
||||
3. Critique your own work before touching the image. Check the palette
|
||||
against every PALETTE RULES line, against your territory's hue
|
||||
ground, and against every other row of the TERRITORIES block; check
|
||||
the concept against every CONCEPT RULES line. Name each failure and
|
||||
fix it. A primary that drifted into another territory's hue family,
|
||||
or into an anchor you do not own, is a failure to fix now, not one
|
||||
to ship.
|
||||
|
||||
4. Build the hero prompt from the HERO PROMPT skeleton below and
|
||||
generate the HERO image at 1500x1500 or the nearest supported
|
||||
square. The image must be square: a size line inside the prompt
|
||||
does not pin the canvas, so whenever the tool accepts a size or
|
||||
aspect-ratio parameter, pass square (1:1) explicitly; the compile
|
||||
step rejects non-square images, and the fix is regenerating with
|
||||
that parameter actually set, not editing the file. Five sibling
|
||||
specialists share the generation tool's output folder, so a default
|
||||
output name is a race that hands you a sibling's image: if the tool
|
||||
accepts an output filename, pass [slug]-hero.png, and work only with
|
||||
the exact file path the tool reports back for YOUR generation. A
|
||||
tool that ignores directory paths and saves to its own fixed folder
|
||||
is normal, not an error: after the inspection below, copy the
|
||||
reported file to [visual-cues dir]/[slug]-hero.png and report the
|
||||
copy's path.
|
||||
|
||||
Open the result and inspect it once, four checks, each with at most
|
||||
one retry, all inside the three-call budget; keep the last result
|
||||
regardless.
|
||||
- Ownership: the scene is yours, staging your palette; a wrong
|
||||
subject or palette means you picked up a sibling's file from the
|
||||
race above, so regenerate once with the [slug] filename.
|
||||
- Wordless: any lettering, numeral, label, or swatch strip anywhere
|
||||
in the frame fails the cue; regenerate once, same prompt, plus
|
||||
"The image contains no lettering, numerals, or graphic marks of
|
||||
any kind; every surface is plain and unprinted."
|
||||
- Real estate: if the palette's fields read as slivers, with frame
|
||||
spent on a blurred background, a bare wall, an empty room, or a
|
||||
whole person instead of the four colors, regenerate once, same
|
||||
prompt, plus "Frame tighter on the scene's four color carriers;
|
||||
every surface in frame carries one of the four colors, and
|
||||
everything is in crisp sharp focus, no blur anywhere."
|
||||
- Light: if the image is dim, dusky, or nocturnal, with palette
|
||||
colors sinking into shadow, or the neutral's ground renders
|
||||
visibly darker than its composed color (off-white linen reading
|
||||
as mid-gray), regenerate once, same prompt, plus "Render the
|
||||
scene in bright, generous daylight-quality studio light; every
|
||||
color fully lit and clearly readable, the ground pale and evenly
|
||||
lit edge to edge, no darkness anywhere in the frame."
|
||||
|
||||
5. Reply with exactly these three lines and nothing else, the path
|
||||
being the file you verified in step 4:
|
||||
|
||||
COMPLETED [slug]
|
||||
HERO [absolute path to the hero PNG]
|
||||
PALETTE primary=#RRGGBB;secondary=#RRGGBB;tertiary=#RRGGBB;neutral=#RRGGBB
|
||||
|
||||
If the budget runs out first, reply instead with the ERROR line plus
|
||||
one line for each thing you finished before the failure, so a retry
|
||||
can start where you stopped:
|
||||
|
||||
ERROR [persona number] [short reason]
|
||||
PALETTE primary=#RRGGBB;secondary=#RRGGBB;tertiary=#RRGGBB;neutral=#RRGGBB
|
||||
HERO-PROMPT [the finished hero prompt, on one line]
|
||||
```
|
||||
|
||||
### The spawn task
|
||||
|
||||
Each spawn task is a few lines; the brief file carries the weight. The persona's method, the off-limits map, and the anchor rule all live in the brief; do **not** paste them back into the tasks, that is the retyping the brief file exists to kill:
|
||||
|
||||
```text
|
||||
You are a color specialist. Read [absolute path to
|
||||
.impeccable/visual-cues/brief.md] now, before anything else, and follow
|
||||
it exactly: it carries your brief, your persona's method, the territory
|
||||
map, craft rules, prompt skeleton, work steps, generation budget, and
|
||||
report format.
|
||||
|
||||
You are persona [N], [persona name].
|
||||
YOUR TERRITORY: [this persona's one-line territory]
|
||||
|
||||
Your answer is unsuccessful if it occupies the same visual, emotional,
|
||||
or strategic territory as another specialist, or if your primary lands
|
||||
in a hue family another territory claims. Stay inside your own.
|
||||
```
|
||||
|
||||
Six spawns fit the observed Codex ceiling of 6 concurrent subagents, so the wave normally runs whole. If a spawn is rejected with a thread-limit error, collect the accepted spawns, close those agents to release their slots, then run a second pass for the rejects. If every spawn ERRORs because subagents lack the image tool, fall back to Step 4's loop using the territories you already carved. Close every agent after collecting its report. If two reports share a slug, rename one before Step 5 (the compile `--slug` flag controls the filenames).
|
||||
|
||||
Retry an ERROR persona at most once, and never from scratch: the retry task is the original spawn task plus the ERROR report's PALETTE / HERO-PROMPT lines and one added instruction, "these lines are finished work from your first attempt; skip the steps they cover and resume at the first uncovered step." An ERROR persona that fails its retry is dropped; five good cues beat a stalled pipeline.
|
||||
|
||||
Done when: every persona has either a three-line COMPLETED report or an ERROR report.
|
||||
|
||||
## Step 4: Serial path (no subagents)
|
||||
|
||||
Only when the harness has no subagent tool at all: pick the generation path by the same precedence rule, keep the same six territories, and play all **six** personas yourself, one at a time and honestly in-method (the Naturalist names physical sources; the Constraint Poet writes its constraints before composing), following the SPECIALIST BRIEF body from its step 1 (palette inside the territory, concept, hero, look-and-retry) and recording the same facts a subagent would report (slug, hero path, palette). No brief file needed: this document is already in your context. The user still gets six cues; only the clock differs.
|
||||
|
||||
Same done-condition as Step 3, over all six personas.
|
||||
|
||||
## Step 5: Compile
|
||||
|
||||
Before anything else, two gates on the reported heroes:
|
||||
|
||||
- **Unique**: hash every reported hero (`md5 [paths]`); each must be unique. Two identical heroes mean two subagents raced on a shared default output filename; re-spawn one of the pair and take its fresh file before compiling.
|
||||
- **Square**: check every reported hero's dimensions (`sips -g pixelWidth -g pixelHeight [paths]` on macOS); width must equal height. The compile script rejects non-square inputs, and squaring after the fact is off the table (cropping eats scene, padding invents background), so a non-square hero is a failed generation: re-spawn that persona once and take the fresh file. Still non-square after the re-spawn: drop the cue.
|
||||
|
||||
A gate re-spawn follows Step 3's retry pattern: the original spawn task plus the report's PALETTE line (its palette was fine; only the image failed the gate) and the resume instruction, so the retry regenerates the hero without recomposing.
|
||||
|
||||
For each COMPLETED report, run one command, carrying the report's slug and its `PALETTE` line:
|
||||
|
||||
```text
|
||||
node .agent/skills/impeccable/scripts/visual-cues.mjs compile [hero.png] \
|
||||
--slug [slug] \
|
||||
--palette "primary=#RRGGBB;secondary=#RRGGBB;tertiary=#RRGGBB;neutral=#RRGGBB" \
|
||||
--out .impeccable/visual-cues
|
||||
```
|
||||
|
||||
The script copies the hero untouched to `[slug].png` (removing a `[slug]-hero.png` intermediate inside the out dir, so the folder holds one file per cue, not a byte-identical pair); for each palette role it searches the hero for the closest rendered pixel (`snapped`, with its hero position), then updates `cues.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"cues": ["amber-dusk", "coastal-glass"],
|
||||
"palette": {
|
||||
"amber-dusk": { "primary": { "hex": "#B8422E", "snapped": "#B4402F", "at": [312, 540] } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Done when: `cues.json` lists one entry per completed palette and every listed slug has its hero PNG on disk.
|
||||
|
||||
## Step 6: Compose the font pairs
|
||||
|
||||
Run this pass yourself after compiling the cues and before launching the picker. Do not spawn specialists; six pairs need one editor holding the same brand facts and ranking them together.
|
||||
|
||||
Build the composition context from exactly these inputs:
|
||||
|
||||
- **The surface modes** this step names below. The interview asks no typography direction on this path, so the modes, the references, and the assets are the anchor; compose from them instead of asking for a direction.
|
||||
- **The three named references** and **the anti-reference** from the seed interview. The anti-reference is a hard constraint on every pair.
|
||||
- From PRODUCT.md, only `## Users`, `## Product Purpose`, `## Positioning`, and `## Brand Commitments`.
|
||||
- The seed Step 2 asset observations when they exist, with the logo's letterforms as the strongest evidence.
|
||||
|
||||
Do **not** read PRODUCT.md wholesale into this task or add any other section to the composition context. The chosen palette does not exist yet; the picker joins it to the pairs later.
|
||||
|
||||
### Name the surfaces before composing
|
||||
|
||||
A pair that carries a landing page can fail a dashboard outright. The landing page asks the heading face for a six-word line at 40px and up; the dashboard asks the body face for a 12px column label sitting next to a number. Suggest fonts without knowing which of those is on the table and you are guessing at the only question that separates the shortlists.
|
||||
|
||||
So decide first what this product is made of, from PRODUCT.md and the codebase, using the four surface kinds the picker's first question offers: `persuade` (landing, marketing, pricing), `operate` (app UI, dashboards, admin, settings), `read` (docs, articles, guides, changelogs), `experience` (portfolios, galleries, showcases). Name every kind the product already implies, not the one it leads with: a tool with a marketing site and a documentation site is `operate, read, persuade`. This is the same set Step 7 writes into `context.json` as `modes`, so make the judgment once, here, and carry it. No clear signal anywhere leaves the set at `persuade` alone.
|
||||
|
||||
What each surface asks of a pair:
|
||||
|
||||
- **persuade**: the heading face is the page. It has to hold a short line at display size, where counters, joins, and one badly drawn character are all visible at a glance. The body face sets a paragraph and two button labels, so it is asked for less. This is the surface where a face with a point of view earns its place.
|
||||
- **operate**: the body face is also the interface face, and it works between 11px and 14px on column headings, form labels, menu items, and numbers in a row. Ask it for a 1, l, and I that stay apart, a 0 that does not read as O, lining figures, and a medium or semibold that the family actually draws rather than one the browser fakes. Headings here are 16px to 24px panel titles set many times per screen, so a face that only comes alive at poster size is the wrong heading for this surface.
|
||||
- **read**: the body face carries hundreds of words at 16px to 18px across a 60 to 75 character line, which is the hardest job on this list. Ask it for a generous x-height, an italic the family drew rather than sloped, and a bold that still reads inline. The heading face sits inside running text at 1.2 to 1.6 times the body, close enough that a mismatch in proportion shows immediately.
|
||||
- **experience**: the work is the subject and the type is the room around it. The heading face can be the most expressive of the six pairs. The body face sets captions, credits, and index metadata at 11px to 13px, often tracked out in caps, so it has to stay even when letter-spaced and survive at those sizes.
|
||||
|
||||
Rank against the strictest surface in the set, never the loudest. Operate and read set the floor the body face has to clear; persuade and experience set how far the heading is allowed to go. Every pair still has to serve every named surface: the user picks one pair for the whole product, and one type system comes out the other end. A pair that only holds up on one surface belongs at the bottom of the list, or off it. None of this loosens [new-work.md](new-work.md)'s `rule:skill-typo-reflex-faces`, which rules all six pairs whatever the surfaces are.
|
||||
|
||||
Compose six distinct territories, then resolve each into one heading and body pair:
|
||||
|
||||
- Spread the six across type directions that serve the surface set (serif display + sans body, single sans, display + mono, and their neighbours), each pair's voice argued from a named reference, an asset letterform, or a PRODUCT.md brand fact. No two pairs may share a heading family or read as the same voice.
|
||||
- Apply [new-work.md](new-work.md)'s `rule:skill-typo-reflex-faces` as the canonical denylist and subject-world test. A family the user named in the interview or supplied assets is the only exception.
|
||||
- Follow [typeset.md](typeset.md)'s workhorse discipline. Give the heading a point of view; give the body a real text face that stays legible at 15px and provides regular and bold weights. A display face in the body slot fails the pair. Where the surface set names `operate` or `read`, that 15px floor is not the test the body face has to pass: the sizes in those two entries above are.
|
||||
- Verify every family exists on Google Fonts under the exact current name. Spelling is part of correctness; use `Source Sans 3`, never a retired family name.
|
||||
- Every pair uses Latin-script faces, and the specimen headline and preview copy are written in English, even when the product's own language is not. Multilingual and CJK support is not built yet: a non-Latin face renders the picker's previews and scale sheets wrong, so English stands in for now. TODO: language-aware pairs that match PRODUCT.md's language and load the right Google Fonts subsets, once the picker's previews support them.
|
||||
- Write `why` as three to five words naming the pair's voice, not a sentence about the brand. The picker sets it in tracked caps under the two family names, so anything longer wraps and stops scanning. `Considered and editorial`, not `Source Serif 4 gives the questionnaire an editorial voice while Source Sans 3 keeps guidance easy to scan`.
|
||||
- Order the pairs best-first, judged on the strictest surface in the set. `pairs[0]` is the recommendation and reaches the picker pre-selected.
|
||||
|
||||
Choose the headline and every wireframe label from the product's own world. Do not invent claims or use placeholder prose that could describe any brand.
|
||||
|
||||
- **Hero**: a headline of at most six words (`specimen.headline`).
|
||||
- **Wireframe**: every other label the type-preview artboard shows (`preview`): a short brand mark, four nav labels, nav and menu actions, two CTA labels, four proof chips, a section title, one section link, three gallery cards (`title` + `meta`), four footer links, and a footer mark. Pull each string from PRODUCT.md, the interview, or supplied assets. Keep labels short enough to fit the artboard.
|
||||
|
||||
**Running text is not yours to write.** The picker sets every paragraph in lorem, because a body face is judged on texture and real prose pulls the eye into reading it instead. Leave `specimen.body` and `preview.sectionBody` out of the file.
|
||||
|
||||
Write `.impeccable/visual-cues/fonts.json` with this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"specimen": {
|
||||
"headline": "Six words from the product's world"
|
||||
},
|
||||
"preview": {
|
||||
"brand": "Ab",
|
||||
"nav": ["Shop", "Stories", "Visit", "About"],
|
||||
"navAction": "Order",
|
||||
"menuAction": "Menu",
|
||||
"ctaPrimary": "Primary action",
|
||||
"ctaSecondary": "Secondary action",
|
||||
"proof": ["Proof one", "Proof two", "Proof three", "Proof four"],
|
||||
"sectionTitle": "Section title",
|
||||
"sectionLink": "Section link",
|
||||
"gallery": [
|
||||
{ "title": "Card one", "meta": "Detail one" },
|
||||
{ "title": "Card two", "meta": "Detail two" },
|
||||
{ "title": "Card three", "meta": "Detail three" }
|
||||
],
|
||||
"footerLinks": ["Link one", "Link two", "Link three", "Link four"],
|
||||
"footerMark": "© Brand"
|
||||
},
|
||||
"pairs": [
|
||||
{
|
||||
"id": "kebab-slug",
|
||||
"name": "Short human label",
|
||||
"heading": { "family": "Exact Google Fonts Name", "weight": 600 },
|
||||
"body": { "family": "Exact Google Fonts Name", "weight": 400 },
|
||||
"why": "One sentence tying this pair to a named brand fact."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Write exactly six pair entries. Each role carries the single weight it needs; the picker also loads weight 700 for each body family. A per-pair `specimen` or `preview` override may replace the shared strings when the brand evidence warrants it.
|
||||
|
||||
If the references are missing because the interview was skipped, say in one line that the typography set is composed from product truth alone, then compose all six from the four allowed PRODUCT.md sections. Still write the file.
|
||||
|
||||
Parse the finished file as JSON and verify its version, specimen, preview (every field above), six unique ids, six unique heading families, role names, weights, and short `why` fields before continuing.
|
||||
|
||||
Done when: `fonts.json` is parseable, contains exactly six ranked pairs, every family name has been checked against Google Fonts, and the preview copy reads as this product, not generic SaaS filler.
|
||||
|
||||
## Step 7: Launch the picker
|
||||
|
||||
Before launching, write the surface set from Step 6 into `.impeccable/design-context/context.json` as a top-level `modes` array: any of `persuade`, `operate`, `read`, `experience`. Do not re-derive it; the font pairs were composed against that reading, and a second judgment here would hand the user tiles the shortlist never answered to. The picker's first question pre-checks those tiles as its starting point; the user corrects the set by hand, and the final selection returns in the answers as `surface-modes`. Omit the field when the product gave no clear signal; the picker then starts from `persuade` alone.
|
||||
|
||||
In the same write, add a top-level `context` object carrying the chat half of the run. The whole file is `{ "schemaVersion": 1, "modes": [...], "context": {...} }`, and it is the store's copy of what chat learned, because after the last question the picker shows the user a design context document assembled from everything the interview learned, and the browser only knows what it asked itself. Every field is optional and the document renders whatever arrives, so fill what the run actually established and leave out the rest:
|
||||
|
||||
```json
|
||||
"context": {
|
||||
"product": {
|
||||
"name": "[product name]",
|
||||
"purpose": "[one-sentence purpose from PRODUCT.md]",
|
||||
"success": "[the success definition from PRODUCT.md Product Purpose, one line]",
|
||||
"platform": "[bare value from PRODUCT.md Platform: web, ios, android, or adaptive]",
|
||||
"positioning": { "not": "[what it is not, from PRODUCT.md Positioning]", "this": "[what it is instead]" },
|
||||
"clarities": ["[one line per item of PRODUCT.md's what-must-be-clear-first list]"],
|
||||
"conversion": "[primary conversion from PRODUCT.md Product Purpose, one sentence-case action phrase: Book a consultation]",
|
||||
"principles": [{ "title": "[principle name from PRODUCT.md Design Principles]", "detail": "[one clause: what it means for design]" }],
|
||||
"surfaces": { "persuade": "[what this surface is for this product, one line]", "operate": "[...]", "read": "[...]", "experience": "[...]" },
|
||||
"operatingContext": "[one line from PRODUCT.md Operating Context]"
|
||||
},
|
||||
"audience": {
|
||||
"primary": "[who]", "secondary": "[who]",
|
||||
"emotion": "[emotional goal on landing]",
|
||||
"leaving": "[what they should leave with, from the purpose and success definition]",
|
||||
"needs": ["[need]"],
|
||||
"trust": ["[trust trigger, from PRODUCT.md Evidence on Hand and Users]"],
|
||||
"inclusion": ["[who must not be excluded, from PRODUCT.md Accessibility and Inclusion]"]
|
||||
},
|
||||
"brand": {
|
||||
"words": ["[word]"],
|
||||
"personality": "[one sentence from PRODUCT.md Brand Personality]",
|
||||
"principles": ["[one line per principle from PRODUCT.md Product Principles, or the legacy Design Principles heading]"],
|
||||
"voice": [{ "say": "[a concrete line the product would write; 2 to 4 pairs, wording examples, never adjectives]", "not": "[the same message written the way the product refuses to sound]" }],
|
||||
"commitments": ["[one line per commitment from PRODUCT.md Brand Commitments]"]
|
||||
},
|
||||
"assets": [
|
||||
"[asset name: what Step 2 read off it; a plain string when no file was provided]",
|
||||
{ "file": "[filename staged in .impeccable/design-context/assets/]", "kind": "[logo, moodboard, or reference]", "note": "[the one-line Step 2 observation for this file]" }
|
||||
],
|
||||
"color": { "assetLocks": ["[one short color fact an asset fixes, e.g. Primary locked from the logo mark; only when an asset names one]"] },
|
||||
"interview": {
|
||||
"references": [{ "name": "[interview reference, one entry per name]", "takeaway": "[one clause: what this reference lends the design]" }],
|
||||
"antiReference": { "name": "[the interview's anti-reference]", "why": "[one clause: why this is the wrong direction]" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Quote the user's answers, not paraphrases of them; the document labels interview fields as the questions they answered. A missing block renders as a pointer to where that truth lives (PRODUCT.md), so a run with no `context.json` at all still produces a complete document. The document reads each field from `context.json` first and falls back to a legacy `cues.json` that still carries it.
|
||||
|
||||
The optionality is field by field, and the document omits the block of any field that does not arrive, so fill a field only when its PRODUCT.md section or interview answer exists. A legacy PRODUCT.md without Positioning, Platform, Operating Context, or Brand Commitments yields a context without those fields, never an invented value. `product.clarities` carries PRODUCT.md's "What must be clear first" list under a shorter key. `product.conversion` names the single action the product most wants. `product.principles` carries PRODUCT.md's Design Principles, one `{ title, detail }` entry per line. `product.surfaces` maps each mode the run might choose to what that surface is for this product, not the generic tile copy. Only include keys for surfaces that exist in the product; the document reads the map for whichever surfaces the questionnaire chose. `interview.references` and `interview.antiReference` also accept their older shapes, plain strings, which render as the bare pills and single-name callout they always did. Never write `interview.colorStrategy`, `interview.hueAnchor`, `interview.typeDirection`, or `interview.motionEnergy`: the chat interview does not ask those questions on this path, `answers.json` owns color, typography, and motion, and the document already renders its interview-direction blocks only when those keys arrive, so their absence reads as chat silence, not as a gap. `assets` mixes both shapes in one list: a file the user actually provided is staged under `.impeccable/design-context/assets/` (seed Step 2 owns the copy) and written as the object form, which the document renders as an image (a `logo` proofed on the committed primary and neutral grounds, a `moodboard` or `reference` in a wide frame, the note under it); a words-only observation stays the plain string it always was.
|
||||
|
||||
Three of the additions are derived at write time rather than asked: `brand.principles` copies the PRODUCT.md principles list (the current Product Principles heading or the legacy Design Principles one), `brand.voice` distills Brand Personality and Brand Commitments into two to four say / not pairs, each half a concrete line of wording the product would or would not publish, never an adjective, and `color.assetLocks` records color facts the provided assets fix (one short line each, written only when Step 2 actually read such a fact off an asset). None of the three adds an interview question, and all three are omitted rather than invented when their source is missing.
|
||||
|
||||
Five of the questions are then answered per surface rather than once for the whole run, because the answer that suits a marketing page rarely suits the tool it sells: `color-strategy`, `motion-energy` (how much movement there is), `boundary-style` (how sections are separated), `corner-style` (how round shapes are), and `depth-style` (how far off the page things sit). Each of the five comes back twice over. The bare key holds the leading surface's answer, which is the first chosen tile in tile order and the one every later screen previews. Alongside it is one `<key>-<mode>` key for every surface chosen, `<mode>` being `persuade`, `operate`, `read`, or `experience`. Surfaces the user never opened are included too, holding the default for their kind; a surface nobody chose returns nothing at all.
|
||||
|
||||
`motion-energy` is the one exception to that shape, because the question is only put to two of the four surfaces. A landing page and a portfolio are watched, so how much they move is a house decision; a tool and a document are worked in, and their movement follows the interface. So the motion keys cover the chosen surfaces among `persuade` and `experience` only, and the bare key holds the first of those two in tile order rather than the run's leading surface: on an app UI plus portfolio run, `motion-energy` is the portfolio's answer. **When a run chooses neither of those surfaces the question is never asked, and no `motion-energy` key comes back at all.** Read it as absent rather than defaulted, and say nothing about movement in DESIGN.md; a default written as a decision is a decision the user never made.
|
||||
|
||||
`layout-structure` (how strict the composition is) is put to the same two surfaces, for the neighbouring reason: on a landing page and a portfolio the composition of the page is the thing being judged, where a tool's regions and a document's single measure come from what they have to hold. It is not a per-surface key, though. One answer is kept for the whole run and every surface is previewed on it, so **it comes back as the bare `layout-structure` and nothing else, owned by the first of `persuade` and `experience` in tile order, and it is absent entirely on a run of neither.** Read that absence the same way: no grid rule in DESIGN.md, and nothing borrowed from the interview to cover the gap.
|
||||
|
||||
The picker does not offer every option on every surface. A landing page can take any answer to all five questions, and the other three surfaces have options withheld from them: a page people work in or read at length is not offered the loudest color or the deepest shadow, a tool is not offered separation by spacing alone, and a portfolio is not offered four working colors or fully round controls. So a value that comes back is one that suits the surface it came from, and a difference between two surfaces is a decision rather than an inconsistency to reconcile.
|
||||
|
||||
When more than one surface comes back, DESIGN.md says what each of them does with color, movement, section separation, corner radius, and depth, instead of stating one answer for the product.
|
||||
|
||||
Tell the user in one line that the visual cues are ready at `.impeccable/visual-cues/` (name the count), then run `node .agent/skills/impeccable/scripts/picker-server.mjs` from the project root as a foreground command and parse its `PICKER_URL` line.
|
||||
|
||||
- **Cursor**: `browser_navigate` to the `PICKER_URL`; that is the in-IDE browser, where the questionnaire belongs. Do not skip this, and do not use the system opener while the tool works. The tab is the user's viewport only; never drive the questionnaire yourself, because the answers are the user's.
|
||||
- **Another harness with a browser tool**: open the URL with that tool, on the same viewport-only rule.
|
||||
- **No browser tool, or the tool call failed**: open the URL with the system opener (macOS `open`, Linux `xdg-open`), then tell the user in one line to finish in the opened tab.
|
||||
- **Even the opener failed**: tell the user *"The design picker is running at [URL]; open it in your browser and finish there."*
|
||||
|
||||
Whichever branch ran, wait on the foreground process.
|
||||
|
||||
A relaunch on a project that has already been through this arrives with the previous answers filled in, and resumes an unfinished run from its own draft; `--fresh` starts blank. [design-context.md](design-context.md) owns that path.
|
||||
|
||||
The server process exiting is the completion signal; never poll or watch the answers file while it runs.
|
||||
|
||||
- **Exit 0**: read the `ANSWERS` path, tell the user the answers were received in one line, then return to [document.md](document.md) Steps 5-6 and write the seed DESIGN.md from that file (its questionnaire-seed mapping owns which key lands where). Do not show or describe the cues or ask for a pick in chat; the picker already settled the pick. The user's tab is meanwhile showing the design context document the picker built from the run, and that document is now a working surface: on submit the server forked a detached edit session (`picker-doc-session.mjs`) that keeps the tab connected. After the seed DESIGN.md is written, enter the edit loop below.
|
||||
- **Exit 2**: tell the user the picker closed unanswered and that they can relaunch it with the same command. Never restart it unprompted.
|
||||
|
||||
## The document edit loop
|
||||
|
||||
The revealed document is editable in place, on live mode's division of labor:
|
||||
|
||||
- **Field edits are applied before you hear about them.** A palette color or a line of product truth is staged in the page, and pressing Apply sends the batch to the session, which writes every value into the store and journals it. What reaches you is the prose those values leave stale: a `save_batch` event naming each change and the document it is owed in.
|
||||
- **Asks in words queue for you from the start.** Font changes (including uploaded faces, saved under `.impeccable/design-context/fonts/`) and freeform requests arrive as `edit_request` events, because there is no value to apply until you decide what it should be.
|
||||
- **The session is the only writer of the store while it runs.** Never write `answers.json` or `context.json` yourself during the loop; attach the values to your reply instead (below) and let the session apply them. DESIGN.md and PRODUCT.md are yours.
|
||||
|
||||
After writing the seed DESIGN.md, tell the user in one line that the document in their tab is live for edits, then poll:
|
||||
|
||||
```
|
||||
node .agent/skills/impeccable/scripts/picker-doc-poll.mjs
|
||||
```
|
||||
|
||||
One-shot, exactly like live mode's poll: it blocks until one event and prints it as JSON. Run it on live mode's harness policy: on Claude Code as a background task; on Cursor as a one-shot poll in a background terminal with notify on `"type":"(edit_request|exit)"`; on Codex as a yielded foreground exec; elsewhere one-shot foreground. Never `--timeout` it short.
|
||||
|
||||
- `{"type":"edit_request", "id", "kind", "prompt", "category", "payload"}`: do the work. Apply the change to DESIGN.md, move any uploaded font files where the project keeps assets, then reply and poll again. Where a questionnaire key names the same fact, attach it rather than writing it, so the tab re-renders it and one process stays in charge of the store:
|
||||
|
||||
```
|
||||
node .agent/skills/impeccable/scripts/picker-doc-poll.mjs --reply <id> done "One line the user sees in the tab"
|
||||
node .agent/skills/impeccable/scripts/picker-doc-poll.mjs --reply <id> done "Swapped the pair" --answers '{"font-heading":"Fraunces"}'
|
||||
```
|
||||
|
||||
Reply `error` with a reason when the ask cannot be applied; reply `retry` to put it back in the queue untouched.
|
||||
- `{"type":"save_batch", "id", "changes", "downstream", "replyCommand"}`: the values are already in the store, so do not apply them again. Read `downstream` and bring each named document in line: `design-md` items are values DESIGN.md states (swap the value, and rename a color whose description no longer fits it), `product-md` items are product truth PRODUCT.md owns. Then reply with the command the event carries. A document that does not exist yet, or a value the document already carries, is success: reply `done`. Reply `error` only when a document exists and cannot be edited.
|
||||
- `{"type":"timeout"}`: nothing arrived in the budget; poll again.
|
||||
- `{"type":"exit"}`: the session ended (tab closed or timed out). Before moving on, read `runtime/journal.jsonl` for `change` entries you never saw a `save_batch` for, which is what a session that died mid-save leaves behind, and reconcile the prose around them. Then stop polling; the loop is over.
|
||||
|
||||
The user may keep working in chat while the document sits open; treat an `edit_request` like any other user instruction, just delivered through the tab.
|
||||
@@ -6,9 +6,7 @@ A probe tests composition, narrative, hierarchy, density, focal moment, signatur
|
||||
|
||||
## Generate three compositional options
|
||||
|
||||
The comp round runs inside the build's phase state: `build-phase.mjs start --direction <seed key> --kind <...>` has already run (the roll's output names the command) and its `comps` phase is open before the first comp is generated; a comp rendered before that sits outside the state, and a session resumed from that point has no phases to follow. `generate-image.mjs` refuses to write under `.impeccable/mocks/` until start has run; a harness-native image tool is bound by the same order.
|
||||
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything is built against it. Comps are the build thread's own work, never delegated: the thread that writes the prompts holds the direction's full context and has seen every comp when the build starts. Open every image by its workspace-relative path; sandboxed viewers reject absolute paths, and everything under the project root has a relative one. Base the comps on real content and the surface concepts already developed with the user. On an established world, anchor every comp on the real identity: capture a screenshot of a representative existing page and pass it as a reference image (the harness image tool's input image, or `generate-image.mjs --ref`); the prompt leads with the new surface's structure while the reference carries palette, type, and component character, because DESIGN.md words alone drift where a pixel reference does not. On a seed world with no built page to screenshot, the chosen cue (`.impeccable/design-context/cue.png`) is that reference, the staged logo alongside when one exists; the same carry-over rule holds: material, palette, and type character transfer, the cue's own composition does not. Attach the file and restate it: each prompt carries the seed DESIGN.md's **Cue, in words:** passage besides the attached reference, because the reference anchors the render while the restated materials tell the model what to take from it, the way a catalog world's card fields name its materials in words and get them back in pixels. When the seed carries no such passage, open the cue and write it into DESIGN.md's Colors section first ([document.md](document.md)'s Colors mapping names its shape), then prompt from it. Name what the reference contributes and what it must not: chrome, palette, type, and component character carry over; the reference page's own content does not, and a banner, hero, or card lifted verbatim is the reference leaking, not fidelity. Three is the number: one comp invites rubber-stamping; the spread between three surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this discipline, so generate two more that vary what the first held fixed, and send all three to the approval point together. Only a round arriving with no decision comp (a degraded roll, an identity-mode page, a direction pinned without the decision round) renders all three here.
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything is built against it. Comps are the build thread's own work, never delegated: the thread that writes the prompts holds the direction's full context and has seen every comp when the build starts. Open every image by its workspace-relative path; sandboxed viewers reject absolute paths, and everything under the project root has a relative one. Base the comps on real content and the surface concepts already developed with the user. On an established world, anchor every comp on the real identity: capture a screenshot of a representative existing page and pass it as a reference image (the harness image tool's input image, or `generate-image.mjs --ref`); the prompt leads with the new surface's structure while the reference carries palette, type, and component character, because DESIGN.md words alone drift where a pixel reference does not. Name what the reference contributes and what it must not: chrome, palette, type, and component character carry over; the reference page's own content does not, and a banner, hero, or card lifted verbatim is the reference leaking, not fidelity. Three is the number: one comp invites rubber-stamping; the spread between three surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this discipline, so generate two more that vary what the first held fixed, and send all three to the approval point together. Only a round arriving with no decision comp (a degraded roll, an identity-mode page, a direction pinned without the decision round) renders all three here.
|
||||
|
||||
- A comp is a designed surface, not a picture of the subject. Lead the prompt with the surface's own structure: the regions this design has, named in order with their scale relationships; a page with no navigation says so instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally.
|
||||
- The inverse is also a failure: a surface with none of its subject in it. The subject appears as the content the regions hold; the world dresses the frame and never displaces what the frame shows. The deletion usually rides in on the prompt's exclusion list, so exclusions bind invented claims, and a medium ban belongs to the committed imagery stance, never to caution. Before accepting a render, point at the subject; a render that depicts everything about the world and nothing of the subject fails however faithful its atmosphere. Regenerate with the subject's content named region by region.
|
||||
@@ -29,18 +27,30 @@ Do not begin code until the user approves a direction or explicitly delegates th
|
||||
|
||||
This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats comp-round comps with no recorded approval as a material finding; decision comps under `.impeccable/mocks/decision/` are the direction round's hand, not comp-round output, and imply no approval on their own.
|
||||
|
||||
After approval, record the choice where tools can find it: the approved comp's path goes in the surface brief, and its `.json` prompt sidecar gains `"approved": true` (every comp generated through `generate-image.mjs` has one; create it if a native tool didn't). The sidecar travels with the mocks folder, so the approval survives sessions and machines that never see the brief, and it is what `build-phase.mjs advance` reads to close the comps phase. Summarize the composition and the parts of the comp that must not be literalized, return to new-work.md, record the direction contract from the approved concept, and build.
|
||||
After approval, record the choice where tools can find it: the approved comp's path goes in the surface brief, and its `.json` prompt sidecar gains `"approved": true` (every comp generated through `generate-image.mjs` has one; create it if a native tool didn't). The sidecar travels with the mocks folder, so the approval survives sessions and machines that never see the brief. Summarize the composition and the parts of the comp that must not be literalized, return to new-work.md, record the direction contract from the approved concept, and build.
|
||||
|
||||
## After approval: the comp becomes a spec
|
||||
## Inventory implementation fidelity
|
||||
|
||||
The approved comp is a north star for translation into semantic, responsive, accessible code, never a license to recompose: keeping the palette and mood while redrawing the topology is a second art direction. Do not rasterize core UI text or controls. Do not substitute a different visual driver after approval without asking.
|
||||
Before building, read the approved comp as a design system and record it in the brief: component grammar, corner language, line weights, elevation treatment, and the type ramp. Everything the comp does not show gets built from this record; without it the fallback is the model's stock kit of square boxes, 1px grids, bento cells, and hard shadows. Then inventory the comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. The primary action gets its own row with its own medium: when the comp dissolves, stamps, erodes, or otherwise physically works the main CTA, that treatment is signature material on the page's most important element, and shrinking it to a border trick is the compliance-token version of commitment. An element never written down is the element the build silently drops; the direction contract's 150 words cannot carry this list, so it lives here.
|
||||
|
||||
What the comp shows is measured, not remembered. new-work.md section 6 runs the build as phases (`build-phase.mjs`): the spec phase turns the comp into region boxes with sampled palettes (`comp-spec.mjs`), and the medium of every region follows from what the pixels are, never from what feels buildable: a figure, a product object, machinery, any illustration with perspective, shading, or drawing skill in it, and any texture by name (woven cloth, paper grain, fabric, leather, brushed metal) is a `plate` / `image` / `texture` region and ships as a raster; text, controls, chrome, diagrams with countable elements, flat shape systems, and anything that must move, scale, or respond are semantic. Writing "CSS" for a sculpted panel's finish, or a many-vertex `clip-path` for a torn edge, is the quiet deletion of the approved design; the detector's organic-clip-path and buried-raster rules and the hero gate's region scores catch it. Dropping an image-native region is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity.
|
||||
The record is sampled, never estimated: read the comp's page **ground**, each dominant field, and each accent's actual hex from its pixels (ImageMagick, Python with PIL, any pixel-reading tool on the machine) and write the values into the same record. Take a flat field from any interior pixel, a textured or grainy one as the average of an interior patch (crop a swatch, scale it to one pixel), and a gradient as its two end colors; never sample an edge, where antialiasing blends neighbors into colors the design never chose. An adjective is a direction, not a record: cream covers everything from near-white to beige, charcoal a third of the value scale, and wherever no number pins a color, the rendition prior picks the spot. Sampled values supersede the palette chips on the decision and composition cards: those were authored before this comp existed, and a chip that disagrees with the comp's pixels is a draft the approval retired.
|
||||
|
||||
## Plates and provenance
|
||||
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack; so is any texture by name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient is not a texture medium and "layered CSS textures" is not a medium at all. Writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish, is not a medium choice; it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly (diagrams with countable elements, controls, flat shape systems) and ends where drawing skill begins; an instruction-manual world keeps its illustrations as line-art illustrations, not diagrams. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity; "no photography on hand" forbids fake proof, not an illustrated hero.
|
||||
|
||||
Every raster region's plate is produced in the plates phase, before any page code, by the shipped asset producer or in the current thread (`generate-image.mjs --plate <id>`, or the harness image tool with the crop as input and the spec's plate prompt). Generation context is part of the asset: after generating any image with any tool, run `node .agent/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<prompt>"` with the exact string the tool received (`generate-image.mjs` does this itself), so the intent lives inside the file; `--read` recovers it, `--scan <dir>` lists rasters still missing one. The embedded prompt plus the region's row in the spec is the raster's **provenance**, and every raster the artifact references carries it; a sourced, stock, or pre-existing raster embeds its origin instead. A raster created or replaced later, in a fix batch or a reviewer's rebuild, is produced the same way; a raster a fix abandons is deleted in the same batch.
|
||||
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond. A field or texture built from many small elements carries a quantity commitment either way: write down its approximate density and coverage ("thousands of glyphs over two-thirds of the fold, dense at the top fading into the path"), because a field rebuilt at a tenth of its density passes every checklist and still is not the design. TYPE rows carry the same discipline: name the face's compression class, and render one headline word against the comp before building on it; a visibly wider or lighter silhouette means the face is wrong, and every section built on it inherits the miss. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
|
||||
|
||||
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies.
|
||||
|
||||
The comp is a north star, not something to trace, and know what that allows: translation into semantic, responsive, accessible code, never recomposition. Keeping the palette and mood while redrawing the topology is a second art direction, not an adaptation. Do not rasterize core UI text or controls. Do not substitute a different visual driver after approval without asking.
|
||||
|
||||
## Produce only the assets the build needs
|
||||
|
||||
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. Prefer generating build-critical imagery in the build thread when the budget allows; when a subagent produces assets instead, every asset carries its prompt, and the builder reads those prompts before composing. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .agent/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<prompt>"` with the exact string the generation tool received, pasted whole, so the intent lives inside the file and survives copies between machines and harnesses; a summary reconstructed from memory records an asset that was never made. `--read` recovers the prompt from any impeccable-generated image, and `--scan <dir>` lists every raster in a directory still missing one. The embedded prompt plus the asset's row in the written inventory is the raster's **provenance**, and every raster the artifact references carries it; a sourced, stock, or pre-existing raster with no generation prompt embeds its origin instead.
|
||||
|
||||
Provenance is owed for the run, not the build phase: a raster created or replaced later, in a fix batch or a reviewer's rebuild, is produced under this same section, prompt embedded and inventory row added, because the inventory is how the next thread knows what ships. A raster a fix abandons or supersedes is deleted from the assets directory in the same batch; an unreferenced raster with no record is a provenance leak, not a spare.
|
||||
|
||||
When the harness runs subagents, spawn the shipped asset producer every time, even when the inventory's produce bucket looks empty: its manifest is the independent second opinion on your media, and the runs that skipped the spawn are the runs whose cotton became CSS. An honestly empty manifest costs one cheap spawn; a wrongly empty produce bucket costs the build its materials. Use `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Without subagents, produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists.
|
||||
|
||||
Convert images with a converter context.mjs reported at boot (the IMAGE_TOOLS line); probe only when it reported none, at most once per session, never per image.
|
||||
|
||||
Return to [new-work.md](new-work.md) for the direction contract, the phased build, and the finishing pass.
|
||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||
|
||||
@@ -90,9 +90,5 @@
|
||||
"typeset": {
|
||||
"description": "Improves typography by fixing font choices, hierarchy, sizing, weight, and readability so text feels intentional. Use when the user mentions fonts, type, readability, text hierarchy, sizing looks off, or wants more polished, intentional typography.",
|
||||
"argumentHint": "[target]"
|
||||
},
|
||||
"design-context": {
|
||||
"description": "Reopen, revise, export, or import the design interview and its design context document",
|
||||
"argumentHint": "[open|edit|export|import] [bundle-file]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* comp-diff: measure a build screenshot against its approved comp and produce
|
||||
* the evidence a reviewer (human or model) needs to judge fidelity without
|
||||
* trusting anyone's memory of the image.
|
||||
*
|
||||
* node comp-diff.mjs --comp .impeccable/mocks/approved.png --build .impeccable/review/hero-repro.png
|
||||
* node comp-diff.mjs --comp comp.png --build desktop.png --spec .impeccable/build/spec.json --out-dir .impeccable/review/diff
|
||||
* node comp-diff.mjs ... --json # machine-readable report on stdout
|
||||
* node comp-diff.mjs ... --threshold 0.75 # exit 3 when the overall score is below
|
||||
*
|
||||
* Inputs: two PNGs. The build capture may be taller than the comp (a full-page
|
||||
* screenshot); it is scaled to the comp's width and the top comp-height rows
|
||||
* are compared, because the comp is the first viewport. `--align stretch`
|
||||
* squashes the whole build onto the comp instead, for a comp that covers a
|
||||
* whole page.
|
||||
*
|
||||
* Outputs (in --out-dir, default .impeccable/review/diff):
|
||||
* side-by-side.png comp | build, same size, labeled, with the score
|
||||
* heatmap.png build with the difference painted over it (red = wrong)
|
||||
* regions/<id>.png paired crops per region at legible scale, scored
|
||||
* report.json every number below, plus per-region rows
|
||||
*
|
||||
* Scores (0..1): structure (blurred SSIM: is the composition the same?),
|
||||
* color (histogram + dominant palette: is it the same palette at the same
|
||||
* coverage?), detail (high-frequency energy ratio: did the material survive,
|
||||
* or did an illustration become a gradient?), bands (do the horizontal
|
||||
* sections line up?). `overall` weights them 0.35 / 0.25 / 0.25 / 0.15.
|
||||
*
|
||||
* Regions come from --spec (comp-spec.mjs output: normalized boxes) or, with
|
||||
* none, from the comp's own horizontal bands, so the per-region crops exist
|
||||
* either way. Every region row carries the same four scores plus `verdict`:
|
||||
* match (>= 0.8), drift (>= 0.6), missing (detail ratio < 0.35 with structure
|
||||
* < 0.6), or contradicted (everything else). The words are the finish
|
||||
* reviewer's fidelity vocabulary on purpose.
|
||||
*
|
||||
* Exit codes: 0 measured (and above threshold when one is given), 1 usage or
|
||||
* unreadable input, 3 below threshold.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { decodePng, encodePng, loadRaster } from './lib/png.mjs';
|
||||
import { crop, resize, fit, blit, createImage, fillRect, strokeRect, drawLabel } from './lib/raster.mjs';
|
||||
import { structureScore, colorScore, detailScore, diffMap, horizontalBands, bandScore, dominantColors, toGray, blurGray, ssimShifted } from './lib/image-metrics.mjs';
|
||||
|
||||
function arg(name, fallback = null) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
if (i === -1) return fallback;
|
||||
const v = process.argv[i + 1];
|
||||
return v && !v.startsWith('--') ? v : fallback;
|
||||
}
|
||||
const flag = (name) => process.argv.includes(`--${name}`);
|
||||
|
||||
export function readPng(file) {
|
||||
return loadRaster(file).image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale the build to the comp's width; take the top comp-height rows
|
||||
* (align=top), squash the whole build onto the comp (align=stretch), or scale
|
||||
* to cover and center-crop (align=cover, the way `object-fit: cover` will show
|
||||
* a plate whose aspect differs from its region).
|
||||
*/
|
||||
export function alignBuild(comp, build, align = 'top') {
|
||||
if (align === 'stretch') return resize(build, comp.width, comp.height);
|
||||
if (align === 'cover') {
|
||||
const s = Math.max(comp.width / build.width, comp.height / build.height);
|
||||
const scaled = resize(build, build.width * s, build.height * s);
|
||||
return crop(scaled, (scaled.width - comp.width) / 2, (scaled.height - comp.height) / 2, comp.width, comp.height);
|
||||
}
|
||||
const scaled = build.width === comp.width ? build : resize(build, comp.width, Math.round((build.height / build.width) * comp.width));
|
||||
if (scaled.height === comp.height) return scaled;
|
||||
if (scaled.height > comp.height) return crop(scaled, 0, 0, comp.width, comp.height);
|
||||
// shorter than the comp: pad with white so a short page reads as missing content, not as a resize
|
||||
const out = createImage(comp.width, comp.height, [255, 255, 255, 255]);
|
||||
blit(out, scaled, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Weights per region kind: what a region is made of decides what losing it looks like. */
|
||||
const WEIGHTS = {
|
||||
default: { structure: 0.35, color: 0.25, detail: 0.25, bands: 0.15 },
|
||||
plate: { structure: 0.25, color: 0.2, detail: 0.5, bands: 0.05 },
|
||||
image: { structure: 0.25, color: 0.2, detail: 0.5, bands: 0.05 },
|
||||
texture: { structure: 0.15, color: 0.35, detail: 0.5, bands: 0 },
|
||||
text: { structure: 0.5, color: 0.25, detail: 0.15, bands: 0.1 },
|
||||
control: { structure: 0.45, color: 0.35, detail: 0.2, bands: 0 },
|
||||
};
|
||||
|
||||
export function scorePair(a, b, kind = null) {
|
||||
const structure = structureScore(a, b);
|
||||
const color = colorScore(a, b);
|
||||
const detail = detailScore(a, b);
|
||||
const bandsA = horizontalBands(a), bandsB = horizontalBands(b);
|
||||
const bands = bandScore(bandsA, bandsB);
|
||||
const w = WEIGHTS[kind] || WEIGHTS.default;
|
||||
const overall = w.structure * structure + w.color * color.score + w.detail * detail.score + w.bands * bands;
|
||||
return {
|
||||
overall: r4(overall),
|
||||
structure: r4(structure),
|
||||
color: r4(color.score),
|
||||
colorIntersection: r4(color.intersection),
|
||||
paletteMatch: r4(color.paletteMatch),
|
||||
detail: r4(detail.score),
|
||||
detailRaw: r4(detail.rawScore ?? detail.score),
|
||||
detailAdded: r4(detail.addedFraction),
|
||||
bands: r4(bands),
|
||||
_detail: detail,
|
||||
_bands: { comp: bandsA, build: bandsB },
|
||||
};
|
||||
}
|
||||
|
||||
/** Kinds that carry the direction: a wrong one is the wrong page, whatever the mean says. */
|
||||
export const DIRECTION_KINDS = new Set(['plate', 'image', 'text']);
|
||||
|
||||
/** Best small global translation (build relative to comp), in pixels, by blurred-gray SSIM. */
|
||||
export function bestShift(comp, build, workWidth = 256) {
|
||||
const h = Math.max(8, Math.round((comp.height / comp.width) * workWidth));
|
||||
const a = blurGray(toGray(resize(comp, workWidth, h)), 2);
|
||||
const b = blurGray(toGray(resize(build, workWidth, h)), 2);
|
||||
const maxShift = Math.max(2, Math.round(workWidth * 0.04));
|
||||
let best = { dx: 0, dy: 0, score: ssimShifted(a, b, 0, 0) };
|
||||
for (const dy of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
|
||||
for (const dx of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
|
||||
const sc = ssimShifted(a, b, Math.round(dx), Math.round(dy));
|
||||
if (sc > best.score + 0.01) best = { dx: Math.round(dx), dy: Math.round(dy), score: sc };
|
||||
}
|
||||
}
|
||||
const scale = comp.width / workWidth;
|
||||
return { dx: Math.round(best.dx * scale), dy: Math.round(best.dy * scale), score: best.score };
|
||||
}
|
||||
|
||||
export function verdictFor(s, kind = null) {
|
||||
const painted = kind === 'plate' || kind === 'image' || kind === 'texture';
|
||||
// Nothing drawn where the comp drew something is missing whatever the
|
||||
// palette says: a footer strip the build pushed below the fold read as
|
||||
// 'drift' on ground colour alone (detail 4%, structure 92%). detailRaw is
|
||||
// 1 when the comp region itself is calm, so a low value already means the
|
||||
// comp had material there.
|
||||
if (s.detailRaw != null && s.detailRaw < 0.15) return 'missing';
|
||||
if (painted && s.detail < 0.5) return 'missing';
|
||||
// For text, chrome, and controls "missing" means the build has nothing
|
||||
// there, not that a thin strip sits a few pixels off: require the build's
|
||||
// own energy to be near zero relative to the comp (rawScore, before the
|
||||
// added-detail penalty), and drift for a mere misalignment.
|
||||
if (!painted && s.detail < 0.35 && s.structure < 0.6) {
|
||||
if (s.detailRaw != null && s.detailRaw < 0.2) return 'missing';
|
||||
// low detail with structure and palette both holding is grain the build
|
||||
// renders flatter (a spine of rotated type on textured red), not a
|
||||
// different composition
|
||||
if (s.structure >= 0.5 && s.color >= 0.5) return 'drift';
|
||||
return 'contradicted';
|
||||
}
|
||||
if (s.detail < 0.35 && s.structure < 0.6) return 'missing';
|
||||
// Structure is the one thing a wrong-but-busy region cannot fake: noise,
|
||||
// a mirrored crop, a swapped column, a tile shuffle all keep color and
|
||||
// energy and lose structure. Below the floor it is contradicted whatever
|
||||
// the weighted mean says; painted regions with invented detail likewise.
|
||||
if (s.structure < 0.3) return 'contradicted';
|
||||
if (painted && (s.structure < 0.45 || s.detailAdded > 0.4)) return 'contradicted';
|
||||
// Text is set in a substitute face at a slightly different metric almost
|
||||
// always, and blurred SSIM reads glyph shape; a text region with its
|
||||
// structure above the swap floor and its palette intact is drift at worst.
|
||||
// Chasing it past that point is what burned eight to thirteen hero attempts
|
||||
// per build in the first simulated round.
|
||||
if (kind === 'text' && s.color >= 0.5) return s.overall >= 0.8 ? 'match' : 'drift';
|
||||
// Chrome and controls are thin strips whose "detail" is mostly ground grain
|
||||
// (a paper texture the build renders flatter, a scanline). When their
|
||||
// structure and palette hold, low detail is drift, not contradiction.
|
||||
if ((kind === 'chrome' || kind === 'control') && s.structure >= 0.5 && s.color >= 0.5) return s.overall >= 0.8 ? 'match' : 'drift';
|
||||
if (s.overall >= 0.8) return 'match';
|
||||
if (s.overall >= 0.6) return 'drift';
|
||||
return 'contradicted';
|
||||
}
|
||||
|
||||
const r4 = (v) => Math.round(v * 10000) / 10000;
|
||||
|
||||
/** Regions from a spec (normalized boxes) or derived from the comp's bands. */
|
||||
export function resolveRegions(comp, spec) {
|
||||
const regions = [];
|
||||
if (spec && Array.isArray(spec.regions) && spec.regions.length) {
|
||||
for (const r of spec.regions) {
|
||||
const box = r.box || r;
|
||||
if ([box.x, box.y, box.w, box.h].some((v) => typeof v !== 'number')) continue;
|
||||
regions.push({ id: r.id || `region-${regions.length + 1}`, x: box.x, y: box.y, w: box.w, h: box.h, kind: r.kind || null });
|
||||
}
|
||||
if (regions.length) return regions;
|
||||
}
|
||||
const bands = horizontalBands(comp).filter((b) => b.strength > 0.2);
|
||||
const cuts = [0, ...bands.map((b) => b.y), 1].filter((v, i, arr) => i === 0 || v - arr[i - 1] > 0.06);
|
||||
if (cuts[cuts.length - 1] !== 1) cuts.push(1);
|
||||
for (let i = 0; i + 1 < cuts.length; i++) {
|
||||
regions.push({ id: `band-${i + 1}`, x: 0, y: cuts[i], w: 1, h: cuts[i + 1] - cuts[i], kind: 'band' });
|
||||
}
|
||||
if (regions.length < 2) {
|
||||
return [
|
||||
{ id: 'top', x: 0, y: 0, w: 1, h: 0.5, kind: 'band' },
|
||||
{ id: 'bottom', x: 0, y: 0.5, w: 1, h: 0.5, kind: 'band' },
|
||||
];
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
|
||||
/** Crop a normalized region; regions thinner than 48px in either axis are grown to that so tiny strips do not swing on subpixel noise. */
|
||||
/** Bounding box of ink (pixels darker/lighter than the region's ground by a margin) within a crop, in px. */
|
||||
export function inkBox(img) {
|
||||
const g = toGray(img);
|
||||
// ground = median gray; ink = |v - ground| > 48
|
||||
const sample = []; for (let i = 0; i < g.data.length; i += Math.max(1, Math.floor(g.data.length / 4000))) sample.push(g.data[i]);
|
||||
sample.sort((p, q) => p - q); const ground = sample[Math.floor(sample.length / 2)] || 255;
|
||||
let x0 = img.width, y0 = img.height, x1 = -1, y1 = -1;
|
||||
for (let y = 0; y < img.height; y++) for (let x = 0; x < img.width; x++) {
|
||||
if (Math.abs(g.data[y * img.width + x] - ground) > 48) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
|
||||
}
|
||||
if (x1 < 0) return null;
|
||||
return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
|
||||
}
|
||||
|
||||
function regionCrop(img, r) {
|
||||
const minPx = 48;
|
||||
let x = r.x * img.width, y = r.y * img.height, w = r.w * img.width, h = r.h * img.height;
|
||||
if (h < minPx) { y -= (minPx - h) / 2; h = minPx; }
|
||||
if (w < minPx) { x -= (minPx - w) / 2; w = minPx; }
|
||||
return crop(img, x, y, w, h);
|
||||
}
|
||||
|
||||
const HEAT_LABEL = { match: [40, 160, 80, 255], drift: [220, 160, 30, 255], missing: [200, 40, 40, 255], contradicted: [200, 40, 40, 255] };
|
||||
|
||||
export function renderSideBySide(comp, build, label, score) {
|
||||
const gap = 24, pad = 48;
|
||||
const targetW = Math.min(comp.width, 1400);
|
||||
const a = fit(comp, targetW, 100000), b = resize(build, a.width, a.height);
|
||||
const out = createImage(a.width * 2 + gap + pad * 2, a.height + pad * 2 + 24, [24, 24, 28, 255]);
|
||||
blit(out, a, pad, pad + 24);
|
||||
blit(out, b, pad + a.width + gap, pad + 24);
|
||||
drawLabel(out, 'COMP', pad, pad - 4, { scale: 2 });
|
||||
drawLabel(out, `BUILD ${label ? label.toUpperCase() : ''}`.trim(), pad + a.width + gap, pad - 4, { scale: 2 });
|
||||
const s = `OVERALL ${(score.overall * 100).toFixed(0)}% STRUCT ${(score.structure * 100).toFixed(0)}% COLOR ${(score.color * 100).toFixed(0)}% DETAIL ${(score.detail * 100).toFixed(0)}% BANDS ${(score.bands * 100).toFixed(0)}%`;
|
||||
drawLabel(out, s, pad, out.height - pad + 8, { scale: 2, bg: HEAT_LABEL[verdictFor(score)] });
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderHeatmap(comp, build) {
|
||||
const map = diffMap(comp, build);
|
||||
const base = resize(build, map.width, map.height);
|
||||
const out = { width: base.width, height: base.height, data: new Uint8Array(base.data) };
|
||||
for (let i = 0, p = 0; i < map.data.length; i++, p += 4) {
|
||||
const d = map.data[i];
|
||||
if (d < 0.12) { // dim what matches so wrong stands out
|
||||
out.data[p] = out.data[p] * 0.55 + 255 * 0.45 * 0.2; out.data[p + 1] = out.data[p + 1] * 0.55; out.data[p + 2] = out.data[p + 2] * 0.55; continue;
|
||||
}
|
||||
const a = Math.min(1, (d - 0.12) / 0.5);
|
||||
out.data[p] = out.data[p] * (1 - a) + 235 * a; out.data[p + 1] = out.data[p + 1] * (1 - a) + 40 * a; out.data[p + 2] = out.data[p + 2] * (1 - a) + 40 * a;
|
||||
}
|
||||
const scaled = resize(out, comp.width, comp.height);
|
||||
drawLabel(scaled, 'DIFF: RED = DIFFERS FROM COMP', 12, 12, { scale: 2 });
|
||||
return scaled;
|
||||
}
|
||||
|
||||
export function renderRegionPair(compCrop, buildCrop, id, score) {
|
||||
const gap = 16, pad = 12;
|
||||
const maxW = 700;
|
||||
const a = fit(compCrop, maxW, 700, true), b = resize(buildCrop, a.width, a.height);
|
||||
const out = createImage(a.width * 2 + gap + pad * 2, a.height + pad * 2 + 30, [24, 24, 28, 255]);
|
||||
blit(out, a, pad, pad + 30);
|
||||
blit(out, b, pad + a.width + gap, pad + 30);
|
||||
const v = verdictFor(score);
|
||||
drawLabel(out, `${id.toUpperCase()} COMP`, pad, pad, { scale: 2 });
|
||||
drawLabel(out, `BUILD ${v.toUpperCase()} ${(score.overall * 100).toFixed(0)}%`, pad + a.width + gap, pad, { scale: 2, bg: HEAT_LABEL[v] });
|
||||
return out;
|
||||
}
|
||||
|
||||
export function compare({ comp, build, spec = null, align = 'top', label = '', kind = null }) {
|
||||
let aligned = alignBuild(comp, build, align);
|
||||
const whole = scorePair(comp, aligned, kind);
|
||||
// Region crops are taken at fixed boxes, so a small global offset (a
|
||||
// taller masthead, a scrollbar) would read every thin region as
|
||||
// contradicted while the whole-image search forgives it. Find the best
|
||||
// global translation once and shift the aligned build by it before
|
||||
// cropping regions; the whole score above stays as measured.
|
||||
// The side-by-side and heatmap show the build as captured; only the
|
||||
// region crops read the shifted copy. (The shifted copy used to be what the
|
||||
// side-by-side drew, and its padding read as a white "letterbox" on the
|
||||
// build in every human review.)
|
||||
const asCaptured = aligned;
|
||||
const shift = bestShift(comp, aligned);
|
||||
if (shift.dx || shift.dy) {
|
||||
const shifted = createImage(aligned.width, aligned.height, [255, 255, 255, 255]);
|
||||
blit(shifted, aligned, -shift.dx, -shift.dy);
|
||||
aligned = shifted;
|
||||
}
|
||||
const regions = resolveRegions(comp, spec).map((r) => {
|
||||
const a = regionCrop(comp, r), b = regionCrop(aligned, r);
|
||||
const s = scorePair(a, b, r.kind);
|
||||
return { ...r, score: strip(s), verdict: verdictFor(s, r.kind), inkBox: { comp: inkBox(a), build: inkBox(b) }, _a: a, _b: b };
|
||||
});
|
||||
const compPalette = dominantColors(comp), buildPalette = dominantColors(aligned);
|
||||
return { label, align, whole: strip(whole), regions, aligned: asCaptured, alignedShifted: aligned, shift, compPalette, buildPalette, _whole: whole };
|
||||
}
|
||||
|
||||
function strip(s) {
|
||||
const { _detail, _bands, ...rest } = s;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function writeArtifacts(result, comp, outDir) {
|
||||
fs.mkdirSync(path.join(outDir, 'regions'), { recursive: true });
|
||||
const side = renderSideBySide(comp, result.aligned, result.label, result.whole);
|
||||
fs.writeFileSync(path.join(outDir, 'side-by-side.png'), encodePng(side));
|
||||
fs.writeFileSync(path.join(outDir, 'heatmap.png'), encodePng(renderHeatmap(comp, result.aligned)));
|
||||
const regionFiles = [];
|
||||
for (const r of result.regions) {
|
||||
const file = path.join(outDir, 'regions', `${r.id}.png`);
|
||||
fs.writeFileSync(file, encodePng(renderRegionPair(r._a, r._b, r.id, r.score)));
|
||||
regionFiles.push(file);
|
||||
}
|
||||
return { sideBySide: path.join(outDir, 'side-by-side.png'), heatmap: path.join(outDir, 'heatmap.png'), regionFiles };
|
||||
}
|
||||
|
||||
export function buildReport(result, files, meta) {
|
||||
return {
|
||||
tool: 'comp-diff',
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
...meta,
|
||||
align: result.align,
|
||||
overall: result.whole.overall,
|
||||
verdict: verdictFor(result.whole),
|
||||
scores: result.whole,
|
||||
palette: { comp: result.compPalette.map(({ hex, coverage }) => ({ hex, coverage })), build: result.buildPalette.map(({ hex, coverage }) => ({ hex, coverage })) },
|
||||
regions: result.regions.map(({ _a, _b, ...r }) => r),
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function summarize(report) {
|
||||
const lines = [];
|
||||
lines.push(`COMP-DIFF ${report.label ? `[${report.label}] ` : ''}overall ${(report.overall * 100).toFixed(0)}% (${report.verdict}) structure ${(report.scores.structure * 100).toFixed(0)}% color ${(report.scores.color * 100).toFixed(0)}% detail ${(report.scores.detail * 100).toFixed(0)}% bands ${(report.scores.bands * 100).toFixed(0)}%`);
|
||||
lines.push(`PALETTE comp ${report.palette.comp.slice(0, 5).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
|
||||
lines.push(`PALETTE build ${report.palette.build.slice(0, 5).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
|
||||
for (const r of report.regions) {
|
||||
lines.push(`REGION ${r.id.padEnd(18)} ${r.verdict.padEnd(12)} ${(r.score.overall * 100).toFixed(0).padStart(3)}% structure ${(r.score.structure * 100).toFixed(0).padStart(3)}% color ${(r.score.color * 100).toFixed(0).padStart(3)}% detail ${(r.score.detail * 100).toFixed(0).padStart(3)}%${r.score.detailAdded > 0.25 ? ' +invented detail' : ''}`);
|
||||
}
|
||||
if (report.files) {
|
||||
lines.push(`FILES side-by-side ${report.files.sideBySide}`);
|
||||
lines.push(`FILES heatmap ${report.files.heatmap}`);
|
||||
lines.push(`FILES regions ${report.files.regionFiles.length} under ${path.dirname(report.files.regionFiles[0] || report.files.heatmap)}`);
|
||||
}
|
||||
const worst = [...report.regions].sort((a, b) => a.score.overall - b.score.overall).slice(0, 3);
|
||||
if (worst.length) lines.push(`WORST ${worst.map((r) => `${r.id} (${r.verdict}, ${(r.score.overall * 100).toFixed(0)}%)`).join('; ')}`);
|
||||
lines.push('OPEN the side-by-side and the worst region pairs before deciding anything; the numbers rank, the crops decide.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const compPath = arg('comp'), buildPath = arg('build');
|
||||
if (!compPath || !buildPath) {
|
||||
console.error('usage: comp-diff.mjs --comp <png> --build <png> [--spec spec.json] [--out-dir dir] [--align top|stretch] [--label name] [--threshold 0.75] [--json]');
|
||||
process.exit(1);
|
||||
}
|
||||
let comp, build;
|
||||
try { comp = readPng(compPath); } catch (e) { console.error(`comp-diff: cannot read comp ${compPath}: ${e.message}`); process.exit(1); }
|
||||
try { build = readPng(buildPath); } catch (e) { console.error(`comp-diff: cannot read build ${buildPath}: ${e.message}`); process.exit(1); }
|
||||
let spec = null;
|
||||
const specPath = arg('spec');
|
||||
if (specPath) {
|
||||
try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); } catch (e) { console.error(`comp-diff: cannot read spec ${specPath}: ${e.message}`); process.exit(1); }
|
||||
}
|
||||
const outDir = arg('out-dir', path.join(path.dirname(buildPath), 'diff'));
|
||||
const label = arg('label', path.basename(buildPath, '.png'));
|
||||
const result = compare({ comp, build, spec, align: arg('align', 'top'), label });
|
||||
const files = flag('no-files') ? null : writeArtifacts(result, comp, outDir);
|
||||
const report = buildReport(result, files, { label, comp: compPath, build: buildPath, spec: specPath || null, compSize: `${comp.width}x${comp.height}`, buildSize: `${build.width}x${build.height}` });
|
||||
if (files) fs.writeFileSync(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2));
|
||||
if (flag('json')) console.log(JSON.stringify(report, null, 2));
|
||||
else console.log(summarize(report));
|
||||
const threshold = arg('threshold') ? parseFloat(arg('threshold')) : null;
|
||||
if (threshold != null && report.overall < threshold) {
|
||||
if (!flag('json')) console.log(`BELOW THRESHOLD ${(threshold * 100).toFixed(0)}%: the reproduction is not done. Fix the worst regions and re-run; do not build past the hero.`);
|
||||
process.exit(3);
|
||||
}
|
||||
}
|
||||
|
||||
// realpath on both sides: a skill mounted through a symlink (Cursor, a
|
||||
// worktree, an eval stage) must still run as a CLI.
|
||||
const isMain = (() => {
|
||||
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
|
||||
catch { return !!process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); }
|
||||
})();
|
||||
if (isMain) main();
|
||||
@@ -1,513 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* comp-spec: turn an approved comp into a measured build spec, so the build
|
||||
* codes against numbers and crops instead of a memory of the image.
|
||||
*
|
||||
* Step 1, look at the comp with a coordinate grid on it:
|
||||
* node comp-spec.mjs --comp .impeccable/mocks/approved.png --grid
|
||||
* writes .impeccable/build/comp-grid.png (10x10 labeled grid, A-J / 0-9)
|
||||
* and prints the measured palette and horizontal bands. Open the grid
|
||||
* image and name every salient region by its grid span.
|
||||
*
|
||||
* Step 2, write the regions file (JSON) and measure it:
|
||||
* node comp-spec.mjs --comp <comp> --regions regions.json
|
||||
* regions.json: { "regions": [ { "id": "exploded-plate", "kind": "plate",
|
||||
* "grid": "E0:J4", "note": "exploded carburetor line drawing" }, ... ] }
|
||||
* `grid` is "<colrow>:<colrow>" inclusive (A0 top-left cell to J9 bottom
|
||||
* right); `box` { x, y, w, h } normalized 0..1 is accepted instead. `kind`
|
||||
* is one of plate | image | texture | text | control | chrome | band.
|
||||
* Writes .impeccable/build/spec.json: every region with its normalized
|
||||
* box, pixel box, sampled palette, detail energy, and its medium: raster
|
||||
* for plate / image / texture (produced as a plate, never CSS), semantic
|
||||
* for text / control / chrome. `--auto` proposes band regions from the
|
||||
* comp itself when you have no regions file yet.
|
||||
*
|
||||
* Step 3, use it:
|
||||
* node comp-spec.mjs --print # compact spec for the build thread
|
||||
* node comp-spec.mjs --crop exploded-plate --out tmp/plate-src.png [--scale 2] [--raw]
|
||||
* crops the region from the comp (reference for a plate regeneration; a
|
||||
* crop is never a shipping asset, its resolution is comp grade). For a
|
||||
* raster region the crop has overlapping text/control/chrome regions
|
||||
* painted out, matching what the plate prompt asks the generator to
|
||||
* remove; --raw keeps them.
|
||||
* node comp-spec.mjs --plate-prompt exploded-plate # the regeneration prompt for that region
|
||||
*
|
||||
* comp-diff.mjs reads the same spec (`--spec`) so its region rows and this
|
||||
* file's rows are the same rows.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { decodePng, encodePng, loadRaster } from './lib/png.mjs';
|
||||
import { crop, resize, fillRect, strokeRect, drawLabel, drawText } from './lib/raster.mjs';
|
||||
import { dominantColors, horizontalBands, detailGrid } from './lib/image-metrics.mjs';
|
||||
|
||||
function arg(name, fallback = null) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
if (i === -1) return fallback;
|
||||
const v = process.argv[i + 1];
|
||||
return v && !v.startsWith('--') ? v : fallback;
|
||||
}
|
||||
const flag = (name) => process.argv.includes(`--${name}`);
|
||||
|
||||
export const BUILD_DIR = path.join('.impeccable', 'build');
|
||||
export const SPEC_PATH = path.join(BUILD_DIR, 'spec.json');
|
||||
export const GRID_PATH = path.join(BUILD_DIR, 'comp-grid.png');
|
||||
export const PLATES_DIR = path.join('assets', 'plates');
|
||||
|
||||
export const RASTER_KINDS = new Set(['plate', 'image', 'texture']);
|
||||
export const KINDS = new Set(['plate', 'image', 'texture', 'text', 'control', 'chrome', 'band']);
|
||||
const COLS = 'ABCDEFGHIJ';
|
||||
|
||||
/** "E0:J4" -> normalized box (inclusive cell span on a 10x10 grid). */
|
||||
export function gridToBox(span) {
|
||||
const m = /^([A-J])(\d):([A-J])(\d)$/i.exec(String(span).trim());
|
||||
if (!m) throw new Error(`grid span "${span}" is not <colrow>:<colrow>, e.g. E0:J4`);
|
||||
const c0 = COLS.indexOf(m[1].toUpperCase()), r0 = +m[2], c1 = COLS.indexOf(m[3].toUpperCase()), r1 = +m[4];
|
||||
const x0 = Math.min(c0, c1), x1 = Math.max(c0, c1), y0 = Math.min(r0, r1), y1 = Math.max(r0, r1);
|
||||
return { x: x0 / 10, y: y0 / 10, w: (x1 - x0 + 1) / 10, h: (y1 - y0 + 1) / 10 };
|
||||
}
|
||||
|
||||
export function renderGrid(comp) {
|
||||
const targetW = Math.min(1536, comp.width);
|
||||
const img = resize(comp, targetW, Math.round((comp.height / comp.width) * targetW));
|
||||
const cw = img.width / 10, ch = img.height / 10;
|
||||
const line = [255, 40, 40, 200];
|
||||
for (let i = 1; i < 10; i++) {
|
||||
fillRect(img, Math.round(i * cw), 0, 1, img.height, line);
|
||||
fillRect(img, 0, Math.round(i * ch), img.width, 1, line);
|
||||
}
|
||||
for (let r = 0; r < 10; r++) for (let c = 0; c < 10; c++) {
|
||||
drawLabel(img, `${COLS[c]}${r}`, Math.round(c * cw) + 3, Math.round(r * ch) + 3, { scale: 2, bg: [0, 0, 0, 170], fg: [255, 230, 120, 255] });
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
function paletteOf(img) {
|
||||
return dominantColors(img, 5).map(({ hex, coverage }) => ({ hex, coverage }));
|
||||
}
|
||||
|
||||
/** Words in a region note that name painted material rather than code-drawn UI. */
|
||||
export const PAINTED_NOTE = /\b(diagram|drawing|drawn|illustration|illustrations|illustrated|figure|schematic|exploded|photo|photos|photograph\w*|picture|painting|painted|render|rendered|rendering|artwork|engraving|etching|linework|line art|texture|textured|textures|grain|fabric|halftone|watercolou?r|sketch|sketched|blueprint|geometry|leader lines?|callout lines?|thumbnail|silhouette|product shot|hero image|3d)\b/i;
|
||||
|
||||
/** A text/control/chrome region larger than this fraction of the comp is a column, not an element. */
|
||||
export const MAX_CODE_REGION_AREA = 0.25;
|
||||
|
||||
/** Fraction of an edge's length the artwork's dark mass has to touch to count as running off the box. */
|
||||
export const EDGE_CONTACT_MIN = 0.35;
|
||||
|
||||
/**
|
||||
* Which edges of a plate region crop the artwork touches. 'Artwork' is the
|
||||
* region's non-ground mass: pixels far from the crop's median gray. A margin
|
||||
* of paper along an edge means the shape ends inside the box; a long run of
|
||||
* ink along it means the shape continues past it.
|
||||
*/
|
||||
export function artworkTouchesEdges(img, { contact = EDGE_CONTACT_MIN, band = 2, ground = null } = {}) {
|
||||
const W = img.width, H = img.height;
|
||||
const gray = new Float32Array(W * H);
|
||||
for (let i = 0, j = 0; i < img.data.length; i += 4, j++) gray[j] = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
||||
// ground is the page's, not the crop's: a region that is mostly a black
|
||||
// arch on paper has a mid-gray median and every edge reads as ink
|
||||
if (ground == null) {
|
||||
const sample = []; for (let i = 0; i < gray.length; i += Math.max(1, Math.floor(gray.length / 5000))) sample.push(gray[i]);
|
||||
sample.sort((a, b) => a - b); ground = sample[Math.floor(sample.length / 2)];
|
||||
}
|
||||
const ink = (x, y) => Math.abs(gray[y * W + x] - ground) > 60;
|
||||
const sides = [];
|
||||
// the longest contiguous run of ink along the edge, as a fraction of it:
|
||||
// an arch cut by the box leaves a long unbroken contact; grain, a rule
|
||||
// crossing, or a line of small type leave short ones
|
||||
const run = (n, at) => { let best = 0, cur = 0; for (let i = 0; i < n; i++) { if (at(i)) { cur++; if (cur > best) best = cur; } else cur = 0; } return best / n; };
|
||||
if (run(H, (y) => { for (let x = 0; x < band; x++) if (ink(x, y)) return true; return false; }) >= contact) sides.push('left');
|
||||
if (run(H, (y) => { for (let x = W - band; x < W; x++) if (ink(x, y)) return true; return false; }) >= contact) sides.push('right');
|
||||
if (run(W, (x) => { for (let y = 0; y < band; y++) if (ink(x, y)) return true; return false; }) >= contact) sides.push('top');
|
||||
if (run(W, (x) => { for (let y = H - band; y < H; y++) if (ink(x, y)) return true; return false; }) >= contact) sides.push('bottom');
|
||||
return sides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shrink a normalized box to the ink inside it (pixels far from the page
|
||||
* ground), padded by `pad` px, never grown. Returns null when the crop has no
|
||||
* ink or the ink fills the box already.
|
||||
*/
|
||||
export function snapBoxToInk(comp, box, ground, { pad = 6, minShrink = 0.06 } = {}) {
|
||||
const px = { x: Math.round(box.x * comp.width), y: Math.round(box.y * comp.height), w: Math.round(box.w * comp.width), h: Math.round(box.h * comp.height) };
|
||||
if (px.w < 8 || px.h < 8) return null;
|
||||
const c = crop(comp, px.x, px.y, px.w, px.h);
|
||||
const W = c.width, H = c.height;
|
||||
let x0 = W, y0 = H, x1 = -1, y1 = -1;
|
||||
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
|
||||
const i = (y * W + x) * 4;
|
||||
const g = 0.299 * c.data[i] + 0.587 * c.data[i + 1] + 0.114 * c.data[i + 2];
|
||||
if (Math.abs(g - ground) > 60) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
|
||||
}
|
||||
if (x1 < 0) return null;
|
||||
// The bounding box of all ink cannot shed a neighbour that shares the
|
||||
// span (a spine at the left edge, the next column's text at the right).
|
||||
// Take the largest connected ink mass instead: cells of `cell` px are
|
||||
// inked when 4% of their pixels are; 8-connected components; the one
|
||||
// with the most inked cells is the element the region names.
|
||||
const cell = Math.max(6, Math.round(Math.min(W, H) / 40));
|
||||
const cw = Math.ceil(W / cell), ch = Math.ceil(H / cell);
|
||||
const on = new Uint8Array(cw * ch), cnt = new Uint16Array(cw * ch);
|
||||
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
|
||||
const i = (y * W + x) * 4;
|
||||
const g = 0.299 * c.data[i] + 0.587 * c.data[i + 1] + 0.114 * c.data[i + 2];
|
||||
if (Math.abs(g - ground) > 60) cnt[Math.floor(y / cell) * cw + Math.floor(x / cell)]++;
|
||||
}
|
||||
for (let i = 0; i < on.length; i++) on[i] = cnt[i] >= cell * cell * 0.04 ? 1 : 0;
|
||||
// dilate by one cell so the letters of a word and the lines of a block
|
||||
// join into one mass; a neighbouring column a few cells away stays apart
|
||||
const grown = new Uint8Array(on.length);
|
||||
for (let y = 0; y < ch; y++) for (let x = 0; x < cw; x++) {
|
||||
if (!on[y * cw + x]) continue;
|
||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { const nx = x + dx, ny = y + dy; if (nx >= 0 && ny >= 0 && nx < cw && ny < ch) grown[ny * cw + nx] = 1; }
|
||||
}
|
||||
const mask = grown;
|
||||
const label = new Int32Array(cw * ch).fill(-1);
|
||||
let best = null;
|
||||
for (let s0 = 0; s0 < on.length; s0++) {
|
||||
if (!mask[s0] || label[s0] >= 0) continue;
|
||||
const stack = [s0]; label[s0] = s0; let n = 0, bx0 = cw, by0 = ch, bx1 = -1, by1 = -1;
|
||||
while (stack.length) {
|
||||
const k = stack.pop();
|
||||
const kx = k % cw, ky = (k / cw) | 0;
|
||||
if (on[k]) { n += cnt[k]; if (kx < bx0) bx0 = kx; if (kx > bx1) bx1 = kx; if (ky < by0) by0 = ky; if (ky > by1) by1 = ky; }
|
||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||
const nx = kx + dx, ny = ky + dy; if (nx < 0 || ny < 0 || nx >= cw || ny >= ch) continue;
|
||||
const nk = ny * cw + nx; if (mask[nk] && label[nk] < 0) { label[nk] = s0; stack.push(nk); }
|
||||
}
|
||||
}
|
||||
// a mass touching the span's left or right edge continues past it (the
|
||||
// spine, the next column); the element the region names sits inside.
|
||||
// Prefer an inside mass unless the edge mass is far heavier.
|
||||
const touchesSide = bx0 === 0 || bx1 === cw - 1;
|
||||
const cand = { n, bx0, by0, bx1, by1, touchesSide };
|
||||
if (!best) best = cand;
|
||||
else if (best.touchesSide && !cand.touchesSide && cand.n * 3 >= best.n) best = cand;
|
||||
else if (!best.touchesSide && cand.touchesSide && cand.n < best.n * 3) { /* keep inside */ }
|
||||
else if (cand.n > best.n) best = cand;
|
||||
}
|
||||
if (best) { x0 = best.bx0 * cell; y0 = best.by0 * cell; x1 = Math.min(W - 1, (best.bx1 + 1) * cell - 1); y1 = Math.min(H - 1, (best.by1 + 1) * cell - 1); }
|
||||
const nx0 = Math.max(0, x0 - pad), ny0 = Math.max(0, y0 - pad), nx1 = Math.min(W, x1 + 1 + pad), ny1 = Math.min(H, y1 + 1 + pad);
|
||||
const shrink = 1 - ((nx1 - nx0) * (ny1 - ny0)) / (W * H);
|
||||
if (shrink < minShrink) return null;
|
||||
return { x: (px.x + nx0) / comp.width, y: (px.y + ny0) / comp.height, w: (nx1 - nx0) / comp.width, h: (ny1 - ny0) / comp.height };
|
||||
}
|
||||
|
||||
function medianGray(img) {
|
||||
const sample = [];
|
||||
const step = Math.max(1, Math.floor((img.width * img.height) / 6000));
|
||||
for (let j = 0; j < img.width * img.height; j += step) { const i = j * 4; sample.push(0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2]); }
|
||||
sample.sort((a, b) => a - b);
|
||||
return sample[Math.floor(sample.length / 2)];
|
||||
}
|
||||
|
||||
function energyOf(img) {
|
||||
const g = detailGrid(img, 4, 4, 256);
|
||||
let s = 0; for (const v of g.cells) s += v;
|
||||
return s / g.cells.length;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Grid cells (10x10) that carry ink the regions do not name. A regions file
|
||||
* that omits the comp's callouts, notes block, or parts table makes those
|
||||
* elements invisible to every later gate (they are never 'missing' if they
|
||||
* were never named), so the spec refuses to close over them. Texture and
|
||||
* band regions do not cover: a full-bleed paper texture names the ground,
|
||||
* not the drawing on it.
|
||||
*/
|
||||
export function uncoveredInkCells(comp, regions) {
|
||||
const grid = detailGrid(comp, 10, 10, 512);
|
||||
const cells = [];
|
||||
// The ground's own energy (paper grain, gradient) is the quietest tenth of
|
||||
// cells; ink is anything clearly above that. Median-relative thresholds
|
||||
// fail on textured comps where every cell carries grain.
|
||||
const energies = [...grid.cells].sort((a, b) => a - b);
|
||||
const ground = energies[Math.floor(energies.length * 0.1)] || 0;
|
||||
const threshold = Math.max(4, ground * 2.2, ground + 12);
|
||||
for (let r = 0; r < 10; r++) for (let c = 0; c < 10; c++) {
|
||||
const e = grid.cells[r * 10 + c];
|
||||
if (e < threshold) continue;
|
||||
const cx = (c + 0.5) / 10, cy = (r + 0.5) / 10;
|
||||
const covered = regions.some((reg) => { const b = reg.coverBox || reg.box; return reg.kind !== 'texture' && reg.kind !== 'band' && cx >= b.x && cx <= b.x + b.w && cy >= b.y && cy <= b.y + b.h; });
|
||||
if (!covered) cells.push(`${COLS[c]}${r}`);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function measureRegions(comp, regionsInput, compPath) {
|
||||
const regions = [];
|
||||
const warnings = [];
|
||||
const seen = new Set();
|
||||
const pageGround = medianGray(comp);
|
||||
for (const raw of regionsInput.regions || []) {
|
||||
if (!raw.id) throw new Error('every region needs an id');
|
||||
if (seen.has(raw.id)) throw new Error(`duplicate region id ${raw.id}`);
|
||||
seen.add(raw.id);
|
||||
const kind = raw.kind && KINDS.has(raw.kind) ? raw.kind : 'band';
|
||||
// Every region says what it is. The note is what the plate prompt, the
|
||||
// gate messages, and the painted-material check read; a regions file of
|
||||
// bare ids and kinds is a list of boxes, and a session that named a
|
||||
// carburetor drawing "chrome" with no note was caught by nothing.
|
||||
if (kind !== 'band' && !(raw.note && String(raw.note).trim().length >= 8)) {
|
||||
throw new Error(`region ${raw.id} has no note. Say in a few words what the comp shows there (the element, its material, its role): the note drives the plate prompt and the gate's messages, and a drawing named as chrome is only caught by what its note says.`);
|
||||
}
|
||||
// The note is the model's own reading of the region. A note that names
|
||||
// painted material (a drawing, diagram, photo, illustration, texture)
|
||||
// filed under a code kind is a plate about to be redrawn in SVG: the
|
||||
// exploded carburetor "chrome" that the hero gate then scores missing.
|
||||
// Refuse at the spec, where the fix is one word, not at the hero.
|
||||
// Escape hatches persist into the spec and announce themselves: a
|
||||
// refusal overridden in regions.json used to vanish from spec.json, so
|
||||
// the shipped spec showed a clean classification with no trace (found in
|
||||
// the ninth sweep, where both carburetor illustrations were filed as
|
||||
// chrome behind codeDrawn: true).
|
||||
for (const key of ['codeDrawn', 'container', 'bleed']) {
|
||||
if (raw[key]) warnings.push(`region ${raw.id}: "${key}": true set in the regions file${key === 'codeDrawn' ? ' (the painted-material refusal is overridden: code draws this region)' : key === 'container' ? ' (the region-size refusal is overridden: one undivided element)' : ' (the clipped-artwork refusal is overridden: the page crops it there)'}`);
|
||||
}
|
||||
if (raw.note && !RASTER_KINDS.has(kind) && kind !== 'band' && PAINTED_NOTE.test(raw.note) && !raw.codeDrawn) {
|
||||
throw new Error(`region ${raw.id} is kind "${kind}" but its note describes painted material ("${raw.note}"). Anything drawn, photographed, or textured ships as a raster plate: set kind to plate (illustration, diagram, figure), image (photograph), or texture (ground). If the note is wrong and code really draws it (a table, a rule, a chrome bar), reword the note or set "codeDrawn": true on the region.`);
|
||||
}
|
||||
let box = raw.box && typeof raw.box.x === 'number' ? raw.box : gridToBox(raw.grid);
|
||||
// A grid span over-covers: a headline named B1:E4 carries the deck below
|
||||
// it and a slice of the next column, and every measurement downstream
|
||||
// (cap height, line count, structure) inherits that slop; a session
|
||||
// wrote a note saying its hero sat at 67 because the boxes straddled
|
||||
// elements, and it was right. Text and control regions snap to the ink
|
||||
// inside their span (page ground as the reference, a small pad); plates,
|
||||
// textures, chrome, and any region given an explicit box are left as
|
||||
// drawn. The grid stays on the record.
|
||||
let coverBox = null;
|
||||
if (!raw.box && raw.grid && (kind === 'text' || kind === 'control') && raw.snap !== false) {
|
||||
const snapped = snapBoxToInk(comp, box, pageGround);
|
||||
if (snapped) { coverBox = box; box = snapped; }
|
||||
}
|
||||
// A code region is one element the page draws: a headline, a table, a
|
||||
// button, a bar. A "chrome" region covering a third of the comp is a
|
||||
// column, and a column scored as one region hides everything inside it
|
||||
// (a session named seven regions for a page with three plates, a table,
|
||||
// a note, callouts and a spine, and the hero gate could name nothing).
|
||||
// Raster regions may be as large as the material; a texture is a sample.
|
||||
const area = box.w * box.h;
|
||||
if (!RASTER_KINDS.has(kind) && kind !== 'band' && area > MAX_CODE_REGION_AREA && !raw.container) {
|
||||
throw new Error(`region ${raw.id} (${kind}) covers ${Math.round(area * 100)}% of the comp; a code region is one element (a headline, a table, a control, a rule, a bar), and one this large is a column holding several. Name each element inside it as its own region (every illustration or photo as a plate), or set "container": true on the region if it truly is one undivided element.`);
|
||||
}
|
||||
const px = { x: Math.round(box.x * comp.width), y: Math.round(box.y * comp.height), w: Math.round(box.w * comp.width), h: Math.round(box.h * comp.height) };
|
||||
const c = crop(comp, px.x, px.y, px.w, px.h);
|
||||
const energy = energyOf(c);
|
||||
const raster = RASTER_KINDS.has(kind);
|
||||
// A plate box that cuts through its own artwork is a plate the page will
|
||||
// crop: object-fit: cover on that box shows the artwork with the side the
|
||||
// box lost, and the hero passed a cover arch cut flat on the left and
|
||||
// bleeding into the footer at 87%. Measure the artwork's edge contact
|
||||
// and say it here, where the fix is a wider grid span.
|
||||
// sides on the comp's own edge do not count: the comp crops there too
|
||||
const atCompEdge = { left: px.x <= 1, top: px.y <= 1, right: px.x + px.w >= comp.width - 1, bottom: px.y + px.h >= comp.height - 1 };
|
||||
const clipped = raster && kind !== 'texture' && !raw.bleed ? artworkTouchesEdges(c, { ground: pageGround }).filter((side) => !atCompEdge[side]) : [];
|
||||
if (clipped.length) warnings.push(`region ${raw.id}: the artwork runs off the box on the ${clipped.join(' and ')} (its ink reaches the edge over ${EDGE_CONTACT_MIN * 100}% of that side). Widen the region so the box holds the whole shape with a margin; a plate placed with object-fit: cover on this box would be cut there.`);
|
||||
regions.push({
|
||||
id: raw.id,
|
||||
kind,
|
||||
note: raw.note || null,
|
||||
grid: raw.grid || null,
|
||||
codeDrawn: raw.codeDrawn ? true : undefined,
|
||||
container: raw.container ? true : undefined,
|
||||
bleed: raw.bleed ? true : undefined,
|
||||
snap: raw.snap === false ? false : undefined,
|
||||
coverBox: coverBox ? { x: r4(coverBox.x), y: r4(coverBox.y), w: r4(coverBox.w), h: r4(coverBox.h) } : undefined,
|
||||
box: { x: r4(box.x), y: r4(box.y), w: r4(box.w), h: r4(box.h) },
|
||||
px,
|
||||
aspect: r4(px.w / px.h),
|
||||
palette: paletteOf(c),
|
||||
detail: { energy: r4(energy) },
|
||||
medium: raw.medium || (raster ? 'raster' : 'semantic'),
|
||||
clipped: clipped.length ? clipped : undefined,
|
||||
plate: raster ? (raw.plate || path.join(PLATES_DIR, `${raw.id}.png`)) : null,
|
||||
text: raw.text || null,
|
||||
});
|
||||
}
|
||||
const uncovered = uncoveredInkCells(comp, regions);
|
||||
if (uncovered.length > 3 && !regionsInput.allowUncovered) {
|
||||
throw new Error(`grid cells ${uncovered.join(', ')} carry ink no region names. Every element the comp shows must be in a region (text, control, chrome, or a plate) so its absence in the build can be measured; add regions for them, or set "allowUncovered": true in the regions file after confirming those cells are empty ground.`);
|
||||
}
|
||||
return {
|
||||
tool: 'comp-spec',
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
comp: compPath,
|
||||
warnings,
|
||||
uncoveredInkCells: uncovered,
|
||||
compSize: { width: comp.width, height: comp.height },
|
||||
aspect: r4(comp.width / comp.height),
|
||||
orientation: comp.width >= comp.height ? 'landscape' : 'portrait',
|
||||
palette: paletteOf(comp),
|
||||
bands: horizontalBands(comp).filter((b) => b.strength > 0.2).map((b) => ({ y: r4(b.y), strength: r4(b.strength) })),
|
||||
regions,
|
||||
};
|
||||
}
|
||||
|
||||
/** Propose regions from the comp's bands when no regions file exists yet. */
|
||||
export function autoRegions(comp) {
|
||||
const bands = horizontalBands(comp).filter((b) => b.strength > 0.2);
|
||||
const cuts = [0, ...bands.map((b) => b.y), 1].filter((v, i, arr) => i === 0 || v - arr[i - 1] > 0.06);
|
||||
if (cuts[cuts.length - 1] !== 1) cuts.push(1);
|
||||
const regions = [];
|
||||
for (let i = 0; i + 1 < cuts.length; i++) regions.push({ id: `band-${i + 1}`, kind: 'band', box: { x: 0, y: cuts[i], w: 1, h: cuts[i + 1] - cuts[i] } });
|
||||
return { regions };
|
||||
}
|
||||
|
||||
const r4 = (v) => Math.round(v * 10000) / 10000;
|
||||
|
||||
/**
|
||||
* The comp crop of a raster region, with every overlapping semantic region
|
||||
* (text, control, chrome) painted out in the crop's own ground color. The
|
||||
* plate prompt tells the generator to remove UI text and chrome, so a good
|
||||
* plate must be scored against a crop that has them removed too; otherwise
|
||||
* the plate loses structure points for obeying the spec.
|
||||
*/
|
||||
export function plateReference(comp, spec, region) {
|
||||
const c = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h);
|
||||
const ground = (region.palette && region.palette[0] && hexToRgb(region.palette[0].hex)) || [255, 255, 255];
|
||||
for (const other of spec.regions || []) {
|
||||
if (other.id === region.id || RASTER_KINDS.has(other.kind) || other.kind === 'band') continue;
|
||||
const ox = Math.max(0, other.px.x - region.px.x), oy = Math.max(0, other.px.y - region.px.y);
|
||||
const ox2 = Math.min(region.px.w, other.px.x + other.px.w - region.px.x), oy2 = Math.min(region.px.h, other.px.y + other.px.h - region.px.y);
|
||||
if (ox2 <= ox || oy2 <= oy) continue;
|
||||
fillRect(c, ox, oy, ox2 - ox, oy2 - oy, [...ground, 255]);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || '');
|
||||
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : null;
|
||||
}
|
||||
|
||||
export function platePrompt(spec, region) {
|
||||
const world = spec.palette.slice(0, 3).map((c) => c.hex).join(', ');
|
||||
const kindLine = region.kind === 'texture'
|
||||
? 'This is a seamless surface texture. Output a tileable texture plate with no objects, no text, no vignette.'
|
||||
: region.kind === 'image'
|
||||
? 'This is a photographic or illustrated image region. Output the same subject, same framing, same lighting.'
|
||||
: 'This is a designed illustration plate. Output the same drawing, same style, same line weight and shading.';
|
||||
return [
|
||||
'Use the provided crop as the approved visual reference and recreate it as a clean production asset at the target aspect ratio.',
|
||||
kindLine,
|
||||
`Preserve silhouette, composition, perspective, palette (${world}), lighting, material, and texture exactly.`,
|
||||
'Remove every piece of UI text, label, caption, button, and interface chrome that is not part of the artwork itself.',
|
||||
'Remove letterboxing, borders, card corners, drop shadows, and any layout background that the page will draw in code.',
|
||||
'Do not add objects. Do not change the concept. Do not restyle. The artwork fills the whole frame edge to edge at the same scale as the reference; no margins, no border, no background band.',
|
||||
region.note ? `Region: ${region.note}.` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function printSpec(spec) {
|
||||
const lines = [];
|
||||
lines.push(`SPEC comp ${spec.comp} ${spec.compSize.width}x${spec.compSize.height} ${spec.orientation}`);
|
||||
lines.push(`PALETTE ${spec.palette.map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
|
||||
lines.push(`BANDS ${spec.bands.map((b) => `${Math.round(b.y * 100)}%`).join(' ') || 'none'}`);
|
||||
for (const r of spec.regions) {
|
||||
const b = r.box;
|
||||
lines.push(`REGION ${r.id.padEnd(18)} ${r.kind.padEnd(8)} ${r.medium.padEnd(8)} box x${Math.round(b.x * 100)}% y${Math.round(b.y * 100)}% w${Math.round(b.w * 100)}% h${Math.round(b.h * 100)}% (${r.px.w}x${r.px.h}px, ${r.aspect}:1) palette ${r.palette.slice(0, 3).map((c) => c.hex).join(' ')}${r.plate ? ` plate ${r.plate}` : ''}${r.note ? ` # ${r.note}` : ''}`);
|
||||
}
|
||||
const plates = spec.regions.filter((r) => r.medium === 'raster');
|
||||
lines.push(`PLATES ${plates.length} to produce: ${plates.map((r) => r.id).join(', ') || 'none'}`);
|
||||
for (const w of spec.warnings || []) lines.push(`WARN ${w}`);
|
||||
lines.push('RULE anything not in this list does not exist on the page: no borders, rules, chrome, or containers the comp does not show. Every raster region ships as its plate, never as CSS.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function loadSpec(specPath = SPEC_PATH) {
|
||||
if (!fs.existsSync(specPath)) return null;
|
||||
return JSON.parse(fs.readFileSync(specPath, 'utf8'));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const specPath = arg('spec', SPEC_PATH);
|
||||
if (flag('help') || process.argv.length <= 2) {
|
||||
console.log(`usage: comp-spec.mjs --comp <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands
|
||||
comp-spec.mjs --comp <png> --regions <json> measure regions -> .impeccable/build/spec.json
|
||||
regions json: { "regions": [ { "id": "art", "kind": "plate|image|texture|text|control|chrome", "grid": "E0:J4", "note": "..." } ] }
|
||||
comp-spec.mjs --comp <png> --auto band regions when you have no regions file
|
||||
comp-spec.mjs --print the compact spec
|
||||
comp-spec.mjs --crop <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)
|
||||
comp-spec.mjs --plate-prompt <id> the regeneration prompt for a raster region`);
|
||||
return;
|
||||
}
|
||||
if (flag('print')) {
|
||||
const spec = loadSpec(specPath);
|
||||
if (!spec) { console.error(`comp-spec: no spec at ${specPath}; run with --comp <png> --regions <json> first`); process.exit(1); }
|
||||
console.log(printSpec(spec));
|
||||
return;
|
||||
}
|
||||
if (arg('plate-prompt')) {
|
||||
const spec = loadSpec(specPath);
|
||||
if (!spec) { console.error(`comp-spec: no spec at ${specPath}`); process.exit(1); }
|
||||
const region = spec.regions.find((r) => r.id === arg('plate-prompt'));
|
||||
if (!region) { console.error(`comp-spec: no region ${arg('plate-prompt')}`); process.exit(1); }
|
||||
console.log(platePrompt(spec, region));
|
||||
return;
|
||||
}
|
||||
if (arg('crop')) {
|
||||
const spec = loadSpec(specPath);
|
||||
if (!spec) { console.error(`comp-spec: no spec at ${specPath}`); process.exit(1); }
|
||||
const region = spec.regions.find((r) => r.id === arg('crop'));
|
||||
if (!region) { console.error(`comp-spec: no region ${arg('crop')}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); }
|
||||
const comp = loadRaster(spec.comp).image;
|
||||
let c = region.medium === 'raster' && !flag('raw') ? plateReference(comp, spec, region) : crop(comp, region.px.x, region.px.y, region.px.w, region.px.h);
|
||||
const scale = parseFloat(arg('scale', '1'));
|
||||
if (scale > 1) c = resize(c, c.width * scale, c.height * scale);
|
||||
const out = arg('out', path.join(BUILD_DIR, 'crops', `${region.id}.png`));
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
fs.writeFileSync(out, encodePng(c, { text: { 'impeccable:crop-of': `${spec.comp}#${region.id}` } }));
|
||||
console.log(`CROP ${out} (${c.width}x${c.height}) region ${region.id} of ${spec.comp}. Reference only: regenerate the plate from it, never ship it.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const compPath = arg('comp');
|
||||
if (!compPath) {
|
||||
console.error('usage: comp-spec.mjs --comp <png> (--grid | --regions <json> | --auto) [--spec out.json]\n comp-spec.mjs --print | --crop <id> [--out file] [--scale n] | --plate-prompt <id>');
|
||||
process.exit(1);
|
||||
}
|
||||
let comp;
|
||||
try { comp = loadRaster(compPath).image; } catch (e) { console.error(`comp-spec: cannot read ${compPath}: ${e.message}`); process.exit(1); }
|
||||
|
||||
if (flag('grid')) {
|
||||
fs.mkdirSync(path.dirname(GRID_PATH), { recursive: true });
|
||||
fs.writeFileSync(GRID_PATH, encodePng(renderGrid(comp)));
|
||||
console.log(`GRID ${GRID_PATH} (${comp.width}x${comp.height} comp; cells A0 top-left to J9 bottom-right)`);
|
||||
console.log(`PALETTE ${paletteOf(comp).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`);
|
||||
console.log(`BANDS ${horizontalBands(comp).filter((b) => b.strength > 0.2).map((b) => `${Math.round(b.y * 100)}%`).join(' ') || 'none'}`);
|
||||
console.log('NEXT open the grid image, then write regions.json in exactly this shape and run --regions regions.json:');
|
||||
console.log(' { "regions": [ { "id": "exploded-plate", "kind": "plate", "grid": "E0:H4", "note": "exploded carburetor drawing" }, { "id": "masthead", "kind": "chrome", "grid": "A0:J0", "note": "navy bar" } ] }');
|
||||
console.log(' kind: plate | image | texture (painted material: every illustration, photograph, figure, product object, texture; each ships as a raster plate) or text | control | chrome (code draws it). grid: <colrow>:<colrow>, A0 top-left to J9 bottom-right, inclusive.');
|
||||
console.log(' A texture region is a clean sample cell of the material (ground with no ink on it), not the whole band it covers; the page tiles it. Ink that sits on the material gets its own text/control region.');
|
||||
return;
|
||||
}
|
||||
|
||||
let regionsInput;
|
||||
if (arg('regions')) {
|
||||
try { regionsInput = JSON.parse(fs.readFileSync(arg('regions'), 'utf8')); } catch (e) { console.error(`comp-spec: cannot read regions ${arg('regions')}: ${e.message}`); process.exit(1); }
|
||||
} else if (flag('auto')) {
|
||||
regionsInput = autoRegions(comp);
|
||||
} else {
|
||||
console.error('comp-spec: pass --grid to get the coordinate grid, then --regions <json> (or --auto for band regions)');
|
||||
process.exit(1);
|
||||
}
|
||||
let spec;
|
||||
try { spec = measureRegions(comp, regionsInput, compPath); } catch (e) { console.error(`comp-spec: ${e.message}`); process.exit(1); }
|
||||
fs.mkdirSync(path.dirname(specPath), { recursive: true });
|
||||
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
||||
console.log(`WROTE ${specPath}`);
|
||||
console.log(printSpec(spec));
|
||||
}
|
||||
|
||||
// realpath on both sides: a skill mounted through a symlink (Cursor, a
|
||||
// worktree, an eval stage) must still run as a CLI.
|
||||
const isMain = (() => {
|
||||
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
|
||||
catch { return !!process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); }
|
||||
})();
|
||||
if (isMain) main();
|
||||
@@ -47,7 +47,6 @@
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/concept-seed.mjs --scope direction --mode persuade
|
||||
* node scripts/concept-seed.mjs --scope direction --mode persuade --seed-declined="<user's verbatim skip answer>"
|
||||
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
|
||||
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
|
||||
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
|
||||
@@ -56,16 +55,6 @@
|
||||
* node scripts/concept-seed.mjs --chosen <challenger-id> --kind challenger --from <key> --scope direction
|
||||
* node scripts/concept-seed.mjs --kind assigned --from <key> --scope direction
|
||||
*
|
||||
* --seed-declined records that the user was offered the document --seed
|
||||
* questionnaire on a no-DESIGN.md project and skipped it. The flag carries
|
||||
* evidence: its value is the user's verbatim skip answer, quoted from the
|
||||
* conversation. Without it, a --scope direction roll on a project with no
|
||||
* DESIGN.md refuses to deal and prints the pause directive instead; a bare
|
||||
* or empty flag refuses the same way (a real live session self-passed the
|
||||
* boolean form without asking, which is why the flag demands the quote).
|
||||
* Fabricating or paraphrasing the quote is a contract violation, not a
|
||||
* shortcut.
|
||||
*
|
||||
* --grain names how much of the product is in play: product, flow, view, or
|
||||
* region. A docs site, an onboarding flow, a landing page and a data table are
|
||||
* four different amounts of product and want different compositions. Grain is a
|
||||
@@ -98,15 +87,10 @@
|
||||
* IMPECCABLE_CATALOG_DIR — directory holding the four catalog JSON files.
|
||||
* IMPECCABLE_API_URL — roll API base (default https://impeccable.style/api).
|
||||
* IMPECCABLE_NO_TELEMETRY — disables the choice ping (DO_NOT_TRACK also honored).
|
||||
* IMPECCABLE_SEED_DECLINED — set to 1 to bypass the seed pause without a
|
||||
* quote; the unattended escape for eval and CI harnesses that
|
||||
* legitimately roll direction on a no-DESIGN.md workspace. Never for
|
||||
* attended sessions; the bypass is logged to stderr so it stays auditable.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
approvedPoolRevision,
|
||||
@@ -629,22 +613,16 @@ rivals to your habitual layout, and keep only what makes this product clearer.${
|
||||
assignment by deal order, so the dice still choose. Verdicts and donations
|
||||
apply between the challengers, weighed against the leader. The pick card
|
||||
sits out; the canon stays, as always.`;
|
||||
// The one command that follows a resolved choice. It records the choice
|
||||
// (anonymous telemetry on API-dealt rolls; skipped under DO_NOT_TRACK /
|
||||
// IMPECCABLE_NO_TELEMETRY) and opens the build's phase machine, whose
|
||||
// first gate is the comp round on a comp-led build. Every run that skipped
|
||||
// the comp round did so by treating a separate "telemetry ping" as
|
||||
// bookkeeping: suppressed with >/dev/null, run after the page was written,
|
||||
// or never run. So there is no separate ping; the start command is the
|
||||
// ping, and it is not optional.
|
||||
const nextCommand = scope === 'direction'
|
||||
? `AFTER THE CHOICE, run exactly one command and follow what it prints (do not suppress its output; do not write page code before it):
|
||||
node ${relative(process.cwd(), here) || '.'}/build-phase.mjs start --direction ${key} --kind <assigned|pick|challenger|canon>${data.source === 'api' ? ' [--chosen <challenger-id>]' : ''}${register ? ` --register ${register}` : ''}
|
||||
It records the choice${data.source === 'api' ? ' (anonymous: card kind plus catalog id; skipped under DO_NOT_TRACK / IMPECCABLE_NO_TELEMETRY)' : ''} and opens the build phases: on a comp-led build the comp round is the first gate (three comps, one approved) and no page code is written before it closes; on a code-led build it prints the contract step. A build without this state file is a build the finish reviewer treats as having skipped the round.\n`
|
||||
: (data.source === 'api'
|
||||
? `AFTER THE CHOICE, run once: node ${relative(process.cwd(), here) || '.'}/concept-seed.mjs --kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''} (records the choice; the locked card's comp is the approved comp, so then: node ${relative(process.cwd(), here) || '.'}/build-phase.mjs start --comp <that comp>).\n`
|
||||
: `AFTER THE CHOICE: the locked card's comp is the approved comp; run node ${relative(process.cwd(), here) || '.'}/build-phase.mjs start --comp <that comp> and follow what it prints.\n`);
|
||||
const telemetryBlock = nextCommand;
|
||||
const telemetryBlock = data.source === 'api'
|
||||
? `TELEMETRY: after the user's choice resolves, rerun this script once with
|
||||
--kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''},
|
||||
adding --chosen <challenger-id> when a dealt challenger won and keeping
|
||||
--register <safer|bolder> when the resolved round came from a steered hand.
|
||||
One ping per resolved attended round. The ping is anonymous, the card kind
|
||||
plus the catalog id when one won; your grounded candidates' names never
|
||||
leave the machine, and the ping is skipped automatically when DO_NOT_TRACK
|
||||
or IMPECCABLE_NO_TELEMETRY is set.\n`
|
||||
: '';
|
||||
const assignedBlock = register === null
|
||||
? `${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
|
||||
${promotedInstruction}
|
||||
@@ -688,58 +666,7 @@ ${restated}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the model must do next, once a direction (or surface structure) is
|
||||
* chosen. Read from the same config the boot directive reads:
|
||||
* `.impeccable/config.local.json` over `.impeccable/config.json`,
|
||||
* `buildPath` comp|code; with neither, comp-led whenever image generation
|
||||
* exists (an OpenAI key here; a harness-native image tool is invisible to
|
||||
* this script, so the text names it too), code-led otherwise.
|
||||
*/
|
||||
export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = process.env } = {}) {
|
||||
let buildPath = null;
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(resolve(cwd, '.impeccable', name), 'utf8'));
|
||||
if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') buildPath = raw.buildPath;
|
||||
} catch { /* absent */ }
|
||||
}
|
||||
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
||||
const scripts = relative(cwd, scriptsDir) || '.';
|
||||
const imageGen = !!env.OPENAI_API_KEY;
|
||||
const seed = key ? ` --direction ${key}` : '';
|
||||
if (buildPath === 'code') {
|
||||
return `NEXT (code-led, from .impeccable config): write the direction contract, then build; no comp round. Load reference/new-work.md section 5 and 6.\n`;
|
||||
}
|
||||
const why = buildPath === 'comp' ? 'from .impeccable config' : imageGen ? 'default: image generation is available' : 'default: comp-led unless no image tool exists; if your harness truly has none and there is no OpenAI key, this is code-led and you say so in one line';
|
||||
if (scope === 'surface') {
|
||||
return `NEXT (comp-led, ${why}): the locked card's comp is the approved comp. Run: node ${scripts}/build-phase.mjs start --comp <that comp> and follow its NEXT lines. Do not write page code before build-phase.mjs advance has closed the spec, plates, and hero gates.\n`;
|
||||
}
|
||||
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`;
|
||||
}
|
||||
|
||||
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()) {
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const fromIdx = args.indexOf('--from');
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
@@ -751,21 +678,6 @@ if (isMainModule()) {
|
||||
const candidateCountIdx = args.indexOf('--candidate-count');
|
||||
const chosenIdx = args.indexOf('--chosen');
|
||||
const kindIdx = args.indexOf('--kind');
|
||||
// --seed-declined carries evidence: the user's verbatim skip answer, in
|
||||
// either --seed-declined="<answer>" or --seed-declined <answer> form. A
|
||||
// bare or empty flag does not count; a live session self-passed the
|
||||
// boolean form without asking the user, so the flag demands the quote.
|
||||
let seedDeclinedAnswer = null;
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
if (args[i] === '--seed-declined') {
|
||||
const next = args[i + 1];
|
||||
seedDeclinedAnswer = next !== undefined && !next.startsWith('--') ? next : '';
|
||||
} else if (args[i].startsWith('--seed-declined=')) {
|
||||
seedDeclinedAnswer = args[i].slice('--seed-declined='.length);
|
||||
}
|
||||
}
|
||||
const seedDeclinedByEnv = process.env.IMPECCABLE_SEED_DECLINED === '1';
|
||||
const seedDeclined = Boolean(seedDeclinedAnswer && seedDeclinedAnswer.trim()) || seedDeclinedByEnv;
|
||||
try {
|
||||
if (chosenIdx !== -1 || kindIdx !== -1) {
|
||||
// Choice ping: always exits 0, telemetry must never fail a design flow.
|
||||
@@ -780,34 +692,13 @@ if (isMainModule()) {
|
||||
register: registerIdx !== -1 ? args[registerIdx + 1] : undefined,
|
||||
});
|
||||
process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n');
|
||||
// The choice is resolved; this is the last script output the model
|
||||
// reads before it decides what to do next, and every run that skipped
|
||||
// the comp round did so right here: prose 20 KB into new-work.md lost
|
||||
// to "direction locked, building now". So the ping prints the next
|
||||
// mandatory step from the recorded build path, and the phase machine
|
||||
// takes it from there.
|
||||
process.stdout.write(nextStepAfterChoice({
|
||||
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
|
||||
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
|
||||
}));
|
||||
} else {
|
||||
// A dealt roll leaves a marker the build phase clears: context.mjs and
|
||||
// detect.mjs read it and refuse to treat page work as done while a
|
||||
// direction is chosen but the build never started (COMP_ROUND_OPEN).
|
||||
try {
|
||||
const { mkdirSync, writeFileSync: wf } = await import('node:fs');
|
||||
if (scopeIdx !== -1 && args[scopeIdx + 1] === 'direction') {
|
||||
mkdirSync(resolve(process.cwd(), '.impeccable', 'build'), { recursive: true });
|
||||
wf(resolve(process.cwd(), '.impeccable', 'build', 'pending.json'), JSON.stringify({ scope: 'direction', at: new Date().toISOString() }, null, 2));
|
||||
}
|
||||
} catch { /* marker is best-effort */ }
|
||||
// Mechanical init gate: prose alone does not keep a model from dealing
|
||||
// before init, and fresh repos produced exactly that skip (the model
|
||||
// rolled directions with no PRODUCT.md, so nothing grounded the fusion).
|
||||
// The --chosen branch above stays ungated; telemetry never blocks.
|
||||
const { loadContext } = await import('./context.mjs');
|
||||
const ctx = loadContext(process.cwd());
|
||||
if (!ctx.hasProduct) {
|
||||
if (!loadContext(process.cwd()).hasProduct) {
|
||||
process.stdout.write([
|
||||
'NO_PRODUCT_MD: the dice stay in the cup until product truth exists.',
|
||||
'Complete the init ask round and write PRODUCT.md first (reference/init.md), then re-run this exact command.',
|
||||
@@ -815,31 +706,6 @@ if (isMainModule()) {
|
||||
].join(' ') + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
// Mechanical seed-pause gate: prose alone did not keep a model from
|
||||
// rolling a direction before offering the seed questionnaire (a real
|
||||
// session read a stale reference file and dealt straight after init),
|
||||
// and a boolean flag did not either (the next session self-passed it
|
||||
// without asking). A direction roll invents the visual world, so on a
|
||||
// project with no DESIGN.md the user gets the choice first; a seed
|
||||
// DESIGN.md counts as present, so a post-questionnaire re-entry never
|
||||
// re-asks. The flag now carries the user's verbatim skip answer.
|
||||
const scopeArg = scopeIdx !== -1 ? args[scopeIdx + 1] : 'surface';
|
||||
if (scopeArg === 'direction' && !ctx.hasDesign && !seedDeclined) {
|
||||
process.stdout.write([
|
||||
'NO_DESIGN_MD: the dice stay in the cup until the user answers the seed question.',
|
||||
'First action: create a tracked todo "Ask user: document --seed or skip" with the harness todo tool and start no other todo until it is answered; with no todo tool, state this gate to the user in chat before anything else.',
|
||||
'Then ask one question: recommend `document --seed`, the guided interview plus browser questionnaire (reference/document.md, seed mode), because a world built from the user\'s own choices beats one assigned to them; offer the skip in the same breath.',
|
||||
'User accepts: run document seed mode; its seed DESIGN.md reads as an established world, so no direction roll happens.',
|
||||
'User skips: re-run this exact command with --seed-declined="<their verbatim skip answer>" quoting the user\'s actual words from this conversation.',
|
||||
'The original build request is never a skip answer, a bare or empty flag refuses again, and a fabricated or paraphrased quote is a contract violation.',
|
||||
].join(' ') + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
if (scopeArg === 'direction' && !ctx.hasDesign && seedDeclinedByEnv && !(seedDeclinedAnswer && seedDeclinedAnswer.trim())) {
|
||||
// Auditable trace for the unattended escape; stderr keeps the seed
|
||||
// output clean for the agent.
|
||||
process.stderr.write('seed pause bypassed via IMPECCABLE_SEED_DECLINED (unattended harness escape)\n');
|
||||
}
|
||||
process.stdout.write(await renderConceptSeed({
|
||||
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : 'surface',
|
||||
key: fromIdx !== -1
|
||||
|
||||
@@ -1171,9 +1171,7 @@ async function cli() {
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
appendDetectorFallback(parts, ctx);
|
||||
appendImageGenDirective(parts);
|
||||
appendDesignContextDirective(parts, ctx);
|
||||
appendBuildPathDirective(parts, ctx);
|
||||
await appendCompRoundOpenDirective(parts, ctx);
|
||||
appendAutonomyCounterDirective(parts);
|
||||
appendSubagentAuthorizationDirective(parts);
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
@@ -1192,9 +1190,7 @@ async function cli() {
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
appendDetectorFallback(parts, ctx);
|
||||
appendImageGenDirective(parts);
|
||||
appendDesignContextDirective(parts, ctx);
|
||||
appendBuildPathDirective(parts, ctx);
|
||||
await appendCompRoundOpenDirective(parts, ctx);
|
||||
appendAutonomyCounterDirective(parts);
|
||||
appendSubagentAuthorizationDirective(parts);
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
@@ -1333,25 +1329,6 @@ function readBuildPathAt(root) {
|
||||
// selecting another workspace, cwd is the caller's app, not the target's, and
|
||||
// letting it rank above the repo root hands one workspace another's workflow.
|
||||
// It stands in only when no project resolved at all.
|
||||
// A direction was dealt for a comp-led build and the phase machine never
|
||||
// started, or stopped short of the hero gate: the comp round is open. Said
|
||||
// here because every model in the corpus ran context.mjs unprompted, and
|
||||
// the run that skipped the round did so between the roll and the first
|
||||
// write; a boot that names the open round is a boot the write cannot claim
|
||||
// it never saw. Reads build-phase's own helper so the two agree.
|
||||
async function appendCompRoundOpenDirective(parts, ctx) {
|
||||
try {
|
||||
const { compRoundOpen } = await import('./build-phase.mjs');
|
||||
const roots = [...new Set([ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((r) => path.resolve(r)))];
|
||||
for (const root of roots) {
|
||||
const open = compRoundOpen(root);
|
||||
if (!open) continue;
|
||||
parts.push(`COMP_ROUND_OPEN: ${open.reason}. On a comp-led build no page code is written before build-phase.mjs closes the comps, spec, plates, and hero gates; run \`node ${path.dirname(fileURLToPath(import.meta.url))}/build-phase.mjs status\` and follow its NEXT line. A page written past an open round is what the finish reviewer sends back.`);
|
||||
return;
|
||||
}
|
||||
} catch { /* build-phase absent: nothing to say */ }
|
||||
}
|
||||
|
||||
function appendBuildPathDirective(parts, ctx) {
|
||||
const roots = [...new Set(
|
||||
[ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((root) => path.resolve(root)),
|
||||
@@ -1369,61 +1346,6 @@ function appendBuildPathDirective(parts, ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// The design interview's durable record: when the user chose the visual world
|
||||
// in the browser questionnaire, the choice survives as files (the cue image
|
||||
// the palette was picked from, staged brand assets, every per-surface answer),
|
||||
// not only as DESIGN.md prose. A later session must learn those files exist
|
||||
// to open them; an older seed DESIGN.md may not name them at all. Bounded
|
||||
// stats on fixed store paths only (Tier 1: no walks, no git).
|
||||
//
|
||||
// The names are spelled here rather than imported from
|
||||
// design-context/store.mjs, which owns them: this script boots every session
|
||||
// and imports only node builtins and ./lib, so a sibling directory missing
|
||||
// from a harness's copy cannot take the whole session down with a resolve
|
||||
// error. Same reason `.impeccable/config.json` is spelled out below. If the
|
||||
// store ever moves, this list moves with it.
|
||||
const DESIGN_CONTEXT_DIR = '.impeccable/design-context';
|
||||
|
||||
function appendDesignContextDirective(parts, ctx) {
|
||||
const roots = [...new Set(
|
||||
[process.cwd(), ctx.projectRoot, ctx.contextDir]
|
||||
.filter(Boolean)
|
||||
.map((dir) => path.resolve(dir)),
|
||||
)];
|
||||
for (const root of roots) {
|
||||
const store = {
|
||||
cuePng: path.join(root, DESIGN_CONTEXT_DIR, 'cue.png'),
|
||||
answersJson: path.join(root, DESIGN_CONTEXT_DIR, 'answers.json'),
|
||||
contextJson: path.join(root, DESIGN_CONTEXT_DIR, 'context.json'),
|
||||
assetsDir: path.join(root, DESIGN_CONTEXT_DIR, 'assets'),
|
||||
};
|
||||
let cueExists = false;
|
||||
let answersExist = false;
|
||||
let contextExists = false;
|
||||
let assetNames = [];
|
||||
try { cueExists = fs.existsSync(store.cuePng); } catch {}
|
||||
try { answersExist = fs.existsSync(store.answersJson); } catch {}
|
||||
try { contextExists = fs.existsSync(store.contextJson); } catch {}
|
||||
try {
|
||||
assetNames = fs.readdirSync(store.assetsDir).filter((name) => !name.startsWith('.'));
|
||||
} catch {}
|
||||
if (!cueExists && !answersExist && assetNames.length === 0) continue;
|
||||
|
||||
const rel = (target) => path.relative(process.cwd(), target) || target;
|
||||
const pieces = [];
|
||||
if (cueExists) pieces.push(`${rel(store.cuePng)} (the image the user picked the palette from)`);
|
||||
if (assetNames.length > 0) pieces.push(`${rel(store.assetsDir)}/ (staged brand material: ${assetNames.join(', ')})`);
|
||||
if (answersExist) pieces.push(`${rel(store.answersJson)} (every questionnaire decision, per surface)`);
|
||||
if (contextExists) pieces.push(`${rel(store.contextJson)} (the interview's chat half, with each staged file's kind and note under context.assets)`);
|
||||
parts.push([
|
||||
'DESIGN_CONTEXT: the visual world on record was chosen by the user in the design interview, and the interview record is files, not only prose: ' + pieces.join('; ') + '.',
|
||||
"Before building or comping any surface on this world, open the cue image and every staged asset; they are the world's pixel truth, and a staged logo is the project's real mark.",
|
||||
'reference/new-work.md names where each rides (comp reference, build material, reviewer calibration).',
|
||||
].join(' '));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Image generation availability: harness-native tools always win, but when the
|
||||
// environment carries an OpenAI key the API fallback works everywhere. The
|
||||
// flag only reports capability, positively: absence stays silent, because a
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
* 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> [--json]
|
||||
* node critique-storage.mjs latest <slug>
|
||||
* 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
|
||||
@@ -28,7 +27,6 @@
|
||||
|
||||
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';
|
||||
@@ -52,45 +50,6 @@ 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
|
||||
@@ -103,27 +62,14 @@ 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 });
|
||||
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}`);
|
||||
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function serializeFrontmatter(obj) {
|
||||
@@ -152,8 +98,6 @@ 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;
|
||||
}
|
||||
@@ -163,7 +107,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(?:~\d{4})?__.+\.md$/;
|
||||
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
|
||||
|
||||
function listSnapshots(suffix, cwd) {
|
||||
const dir = getCritiqueDir(cwd);
|
||||
@@ -174,105 +118,24 @@ function listSnapshots(suffix, cwd) {
|
||||
.map((f) => path.join(dir, f));
|
||||
}
|
||||
|
||||
function readSnapshot(filePath) {
|
||||
function readLatestSnapshotMatching(suffix, cwd) {
|
||||
const filePath = listSnapshots(suffix, cwd).at(-1);
|
||||
if (!filePath) return null;
|
||||
const body = fs.readFileSync(filePath, 'utf-8');
|
||||
return { path: filePath, body, meta: parseFrontmatter(body) };
|
||||
}
|
||||
|
||||
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() } = {}) {
|
||||
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 readLatestSnapshotMatching(`__${slug}.md`, cwd);
|
||||
}
|
||||
|
||||
/** Return the most recent snapshot across all targets, or null. */
|
||||
export function readLatestSnapshotAcrossTargets({ cwd = process.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;
|
||||
return readLatestSnapshotMatching('.md', cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,13 +153,9 @@ 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 (isReadySlug(value)) return value;
|
||||
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
|
||||
return slugFromTarget(value);
|
||||
}
|
||||
|
||||
@@ -322,124 +181,14 @@ 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 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`);
|
||||
const latest = readLatestSnapshot(coerceSlug(args[0]));
|
||||
if (!latest) { process.exit(2); }
|
||||
process.stdout.write(latest.body);
|
||||
return;
|
||||
}
|
||||
case 'trend': {
|
||||
@@ -448,7 +197,7 @@ function main(argv) {
|
||||
return;
|
||||
}
|
||||
default:
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
|
||||
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
[
|
||||
{
|
||||
"family": "Betania Patmos GDL",
|
||||
"weight": 400,
|
||||
"category": "handwriting",
|
||||
"variable": false,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Betania Patmos In GDL",
|
||||
"weight": 400,
|
||||
"category": "handwriting",
|
||||
"variable": false,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Doto",
|
||||
"weight": 300,
|
||||
"category": "sans",
|
||||
"variable": true,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Doto",
|
||||
"weight": 700,
|
||||
"category": "sans",
|
||||
"variable": true,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Jacquard 12 Charted",
|
||||
"weight": 400,
|
||||
"category": "display",
|
||||
"variable": false,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Jacquard 24 Charted",
|
||||
"weight": 400,
|
||||
"category": "display",
|
||||
"variable": false,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Jacquarda Bastarda 9 Charted",
|
||||
"weight": 400,
|
||||
"category": "display",
|
||||
"variable": false,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Jersey 10 Charted",
|
||||
"weight": 400,
|
||||
"category": "display",
|
||||
"variable": false,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Jersey 15 Charted",
|
||||
"weight": 400,
|
||||
"category": "display",
|
||||
"variable": false,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Jersey 20 Charted",
|
||||
"weight": 400,
|
||||
"category": "display",
|
||||
"variable": false,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Jersey 25 Charted",
|
||||
"weight": 400,
|
||||
"category": "display",
|
||||
"variable": false,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Micro 5 Charted",
|
||||
"weight": 400,
|
||||
"category": "display",
|
||||
"variable": false,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Montserrat Underline",
|
||||
"weight": 400,
|
||||
"category": "sans",
|
||||
"variable": true,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Montserrat Underline",
|
||||
"weight": 700,
|
||||
"category": "sans",
|
||||
"variable": true,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Redacted",
|
||||
"weight": 400,
|
||||
"category": "display",
|
||||
"variable": false,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Yarndings 12 Charted",
|
||||
"weight": 400,
|
||||
"category": "display",
|
||||
"variable": false,
|
||||
"reason": "not loaded or no lettering"
|
||||
},
|
||||
{
|
||||
"family": "Yarndings 20 Charted",
|
||||
"weight": 400,
|
||||
"category": "display",
|
||||
"variable": false,
|
||||
"reason": "not loaded or no lettering"
|
||||
}
|
||||
]
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/** Write this project's design context out in two forms.
|
||||
*
|
||||
* node <scripts_path>/design-context-export.mjs [--out DIR] [--no-assets]
|
||||
*
|
||||
* design-context.md one document a reader or another tool can follow
|
||||
* design-context.bundle.json everything needed to rebuild the store elsewhere
|
||||
*
|
||||
* Prints one EXPORTED line per file written. Exit 1 when the project has no
|
||||
* design interview to export.
|
||||
*/
|
||||
|
||||
import { migrate } from './design-context/store.mjs';
|
||||
import { exportDesignContext } from './design-context/portability.mjs';
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node design-context-export.mjs [options]
|
||||
|
||||
Write the design context to a readable document and a portable bundle.
|
||||
|
||||
Options:
|
||||
--out DIR Where to write (default: .impeccable/design-context/exports)
|
||||
--no-assets Leave supplied files and the cue image out of the bundle
|
||||
--help Show this help
|
||||
|
||||
Output:
|
||||
EXPORTED PATH One line per file written
|
||||
|
||||
See reference/design-context.md for the canonical agent flow.`);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const readValue = (name) => {
|
||||
const exact = args.find((arg) => arg.startsWith(`${name}=`));
|
||||
if (exact) return exact.slice(name.length + 1);
|
||||
const at = args.indexOf(name);
|
||||
return at !== -1 && args[at + 1] && !args[at + 1].startsWith('--') ? args[at + 1] : '';
|
||||
};
|
||||
|
||||
const unknown = args.find((arg) => arg.startsWith('--')
|
||||
&& !['--out', '--no-assets', '--help'].some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
|
||||
if (unknown) {
|
||||
console.error(`Unknown option: ${unknown}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await migrate(process.cwd());
|
||||
|
||||
try {
|
||||
const { markdownPath, bundlePath, skipped } = await exportDesignContext(process.cwd(), {
|
||||
outDir: readValue('--out') || undefined,
|
||||
includeAssets: !args.includes('--no-assets'),
|
||||
});
|
||||
for (const entry of skipped) {
|
||||
console.error(`Skipped ${entry.path} (${entry.bytes} bytes): ${entry.reason}`);
|
||||
}
|
||||
console.log(`EXPORTED ${markdownPath}`);
|
||||
console.log(`EXPORTED ${bundlePath}`);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/** Rebuild a design context in this project from a bundle another one exported.
|
||||
*
|
||||
* node <scripts_path>/design-context-import.mjs <bundle.json>
|
||||
* [--design skip|write] [--force]
|
||||
*
|
||||
* Refuses a project that already has a design context unless --force, and
|
||||
* refuses either way while an edit session is running, because the session is
|
||||
* the only writer of the store while it lives.
|
||||
*
|
||||
* Prints IMPORTED <n> files and DESIGN_MD carried|absent for the agent to
|
||||
* branch on. Exit 1 on a bundle this release cannot read.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { migrate, paths, pidAlive, readAnswers, readJsonSoft } from './design-context/store.mjs';
|
||||
import { importDesignContext, validateBundle } from './design-context/portability.mjs';
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node design-context-import.mjs <bundle.json> [options]
|
||||
|
||||
Rebuild this project's design context from an exported bundle.
|
||||
|
||||
Options:
|
||||
--design skip|write Write DESIGN.md when the bundle carries one and this
|
||||
project has none (default: skip)
|
||||
--force Replace an existing design context
|
||||
--help Show this help
|
||||
|
||||
Output:
|
||||
IMPORTED N files
|
||||
DESIGN_MD carried|absent
|
||||
|
||||
See reference/design-context.md for the canonical agent flow.`);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.length || args.includes('--help') || args.includes('-h')) {
|
||||
printHelp();
|
||||
process.exit(args.length ? 0 : 1);
|
||||
}
|
||||
|
||||
const source = args.find((arg) => !arg.startsWith('--'));
|
||||
if (!source) {
|
||||
console.error('Name the bundle to import.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const designAt = args.indexOf('--design');
|
||||
const design = designAt !== -1 && args[designAt + 1] ? args[designAt + 1] : 'skip';
|
||||
if (!['skip', 'write'].includes(design)) {
|
||||
console.error('--design must be skip or write');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await migrate(process.cwd());
|
||||
const target = paths(process.cwd());
|
||||
|
||||
/* A running session holds the store: importing under it would swap the run out
|
||||
from beneath the document someone is reading and the batch it may owe. */
|
||||
const session = await readJsonSoft(target.sessionJson);
|
||||
if (session && pidAlive(session.pid)) {
|
||||
console.error(`A design context document is open on http://127.0.0.1:${session.port}. Close it, then import.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!args.includes('--force') && await readAnswers(process.cwd())) {
|
||||
console.error('This project already has a design context. Re-run with --force to replace it.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let bundle;
|
||||
try {
|
||||
bundle = validateBundle(JSON.parse(await readFile(path.resolve(process.cwd(), source), 'utf8')));
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await importDesignContext(process.cwd(), bundle, { design });
|
||||
console.log(`IMPORTED ${result.written} files`);
|
||||
console.log(`DESIGN_MD ${result.designCarried ? 'carried' : 'absent'}${result.designWritten ? ' written' : ''}`);
|
||||
@@ -1,82 +0,0 @@
|
||||
/** What the design context document lets a person edit, and where it lands.
|
||||
*
|
||||
* Every editable field has an id the browser sends and this file resolves into
|
||||
* a file and a path inside it. That is what makes applying a change a
|
||||
* deterministic write rather than a search: the document names the field, not
|
||||
* the text it happens to hold.
|
||||
*
|
||||
* `file` is the store file the value lives in. For `context`, the path is
|
||||
* relative to the top-level `context` object, so `product.purpose` addresses
|
||||
* `context.product.purpose` inside context.json.
|
||||
*
|
||||
* `downstream` names the document the agent reconciles afterwards. The value
|
||||
* itself is already applied by the time the agent hears about it; what needs a
|
||||
* reader is the prose around it.
|
||||
*/
|
||||
|
||||
const BINDINGS = {
|
||||
'palette.primary': { file: 'answers', path: 'palette-primary', kind: 'color', downstream: 'design-md' },
|
||||
'palette.secondary': { file: 'answers', path: 'palette-secondary', kind: 'color', downstream: 'design-md' },
|
||||
'palette.tertiary': { file: 'answers', path: 'palette-tertiary', kind: 'color', downstream: 'design-md' },
|
||||
'palette.neutral': { file: 'answers', path: 'palette-neutral', kind: 'color', downstream: 'design-md' },
|
||||
|
||||
'product.purpose': { file: 'context', path: 'product.purpose', kind: 'text', maxLen: 600, downstream: 'product-md' },
|
||||
'product.positioning.not': { file: 'context', path: 'product.positioning.not', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
'product.positioning.this': { file: 'context', path: 'product.positioning.this', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
|
||||
'audience.primary': { file: 'context', path: 'audience.primary', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
'audience.secondary': { file: 'context', path: 'audience.secondary', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
'audience.emotion': { file: 'context', path: 'audience.emotion', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
'audience.leaving': { file: 'context', path: 'audience.leaving', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
|
||||
'brand.personality': { file: 'context', path: 'brand.personality', kind: 'text', maxLen: 600, downstream: 'product-md' },
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_LEN = 2000;
|
||||
const HEX = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
export const bindingFor = (id) => (Object.hasOwn(BINDINGS, id) ? BINDINGS[id] : null);
|
||||
|
||||
/**
|
||||
* Turn what a contenteditable produced into something safe to write.
|
||||
*
|
||||
* Everything arriving here was typed into a browser, so it is treated as text
|
||||
* and nothing else: control characters go, newlines collapse (every bound field
|
||||
* is a single line in the document), and the length is capped where the field
|
||||
* says so. A value that survives is a string; a value that cannot be one throws.
|
||||
*/
|
||||
export function sanitizeValue(binding, raw) {
|
||||
if (binding.kind === 'color') {
|
||||
const value = String(raw ?? '').trim().toUpperCase();
|
||||
if (!HEX.test(value)) throw new Error('Expected a #rrggbb color');
|
||||
return value;
|
||||
}
|
||||
|
||||
const text = String(raw ?? '')
|
||||
/* Newlines first, because they are the one control character with a
|
||||
meaning here: a pasted paragraph becomes one line rather than nothing. */
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.replace(/[\u0000-\u001F\u007F]/g, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
if (!text) throw new Error('Expected some text');
|
||||
return text.slice(0, binding.maxLen || DEFAULT_MAX_LEN);
|
||||
}
|
||||
|
||||
/** Read a dotted path out of a plain object, without creating anything. */
|
||||
export function readPath(root, dotted) {
|
||||
return dotted.split('.').reduce((node, key) => (node && typeof node === 'object' ? node[key] : undefined), root);
|
||||
}
|
||||
|
||||
/** Write a dotted path into a plain object, creating the objects on the way. */
|
||||
export function writePath(root, dotted, value) {
|
||||
const keys = dotted.split('.');
|
||||
const last = keys.pop();
|
||||
let node = root;
|
||||
for (const key of keys) {
|
||||
if (!node[key] || typeof node[key] !== 'object' || Array.isArray(node[key])) node[key] = {};
|
||||
node = node[key];
|
||||
}
|
||||
node[last] = value;
|
||||
return root;
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
/** Taking a design context out of a project, and putting one into another.
|
||||
*
|
||||
* Two shapes, because they answer different questions. `design-context.md` is
|
||||
* for a reader, human or otherwise: one document that says what was decided
|
||||
* and why, which can be handed to another tool as the rules to follow. The
|
||||
* bundle is for this toolchain: everything needed to rebuild the store
|
||||
* somewhere else, including the bytes of the files the user supplied.
|
||||
*
|
||||
* The bundle carries the schema version, not the store. A store file's era is
|
||||
* readable from its own keys, and stamping the browser's submission would mean
|
||||
* rewriting what it sent.
|
||||
*/
|
||||
|
||||
import { readFile, mkdir, readdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
paths,
|
||||
readAnswers,
|
||||
readContext,
|
||||
readJsonSoft,
|
||||
writeAnswers,
|
||||
writeContext,
|
||||
writeJsonAtomic,
|
||||
SCHEMA_VERSION,
|
||||
} from './store.mjs';
|
||||
|
||||
const BUNDLE_KIND = 'impeccable-design-context';
|
||||
const BUNDLE_SCHEMA = 1;
|
||||
|
||||
/* Generated cue PNGs run a few megabytes, so the per-file cap must clear
|
||||
them; MAX_BUNDLE_BYTES still bounds the whole. */
|
||||
const MAX_FILE_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_BUNDLE_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const MIME = new Map([
|
||||
['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], ['.gif', 'image/gif'],
|
||||
['.woff2', 'font/woff2'], ['.woff', 'font/woff'], ['.ttf', 'font/ttf'], ['.otf', 'font/otf'],
|
||||
]);
|
||||
|
||||
/* Exactly the three places an export puts bytes, and so exactly the three an
|
||||
import will write them back to. Anything else in a bundle is not ours. */
|
||||
const ALLOWED_FILE = /^(assets\/[^/]+|fonts\/[^/]+|cue\.png)$/;
|
||||
|
||||
const SURFACE_LABELS = { persuade: 'Landing page', operate: 'Tool', read: 'Docs', experience: 'Portfolio' };
|
||||
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral'];
|
||||
const PER_SURFACE = ['color-strategy', 'boundary-style', 'corner-style', 'depth-style', 'motion-energy'];
|
||||
|
||||
/* ============================================================
|
||||
Export
|
||||
============================================================ */
|
||||
|
||||
async function collectFiles(cwd, { includeAssets = true } = {}) {
|
||||
const target = paths(cwd);
|
||||
const files = [];
|
||||
const skipped = [];
|
||||
let total = 0;
|
||||
|
||||
const take = async (absolute, relative) => {
|
||||
let bytes;
|
||||
try {
|
||||
bytes = await readFile(absolute);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (bytes.length > MAX_FILE_BYTES || total + bytes.length > MAX_BUNDLE_BYTES) {
|
||||
skipped.push({ path: relative, bytes: bytes.length, reason: 'too large for the bundle' });
|
||||
return;
|
||||
}
|
||||
total += bytes.length;
|
||||
files.push({
|
||||
path: relative,
|
||||
mime: MIME.get(path.extname(relative).toLowerCase()) || 'application/octet-stream',
|
||||
base64: bytes.toString('base64'),
|
||||
});
|
||||
};
|
||||
|
||||
if (!includeAssets) return { files, skipped };
|
||||
|
||||
for (const [dir, prefix] of [[target.assetsDir, 'assets'], [target.fontsDir, 'fonts']]) {
|
||||
let names = [];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const name of names.sort()) await take(path.join(dir, name), `${prefix}/${name}`);
|
||||
}
|
||||
await take(target.cuePng, 'cue.png');
|
||||
return { files, skipped };
|
||||
}
|
||||
|
||||
async function buildBundle(cwd, { includeAssets = true, now = new Date() } = {}) {
|
||||
const target = paths(cwd);
|
||||
const answers = await readAnswers(cwd);
|
||||
if (!answers) throw new Error('No design interview found. Run /impeccable document to create one.');
|
||||
|
||||
const stored = (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION };
|
||||
const cues = await readJsonSoft(target.cuesJson);
|
||||
const source = typeof answers['palette-source'] === 'string' ? answers['palette-source'] : '';
|
||||
/* A seed or custom palette names no cue, so there is no image and no dealt
|
||||
entry to carry. The hexes in the answers are the palette of record. */
|
||||
const chosenCuePalette = source && cues?.palette?.[source] ? cues.palette[source] : null;
|
||||
|
||||
const { files, skipped } = await collectFiles(cwd, { includeAssets });
|
||||
let designMd = null;
|
||||
try {
|
||||
designMd = await readFile(path.resolve(cwd, 'DESIGN.md'), 'utf8');
|
||||
} catch {
|
||||
/* Not written yet, which an import is told about rather than guessing. */
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: BUNDLE_SCHEMA,
|
||||
kind: BUNDLE_KIND,
|
||||
exportedAt: now.toISOString(),
|
||||
product: { name: stored.context?.product?.name || '' },
|
||||
context: stored,
|
||||
answers,
|
||||
/* Whole, never trimmed: the questionnaire validates the manifest by its
|
||||
pair count and quietly falls back to its own set at any other number. */
|
||||
fonts: await readJsonSoft(target.fontsManifestJson),
|
||||
chosenCue: chosenCuePalette ? { slug: source, palette: chosenCuePalette } : null,
|
||||
designMd,
|
||||
files,
|
||||
...(skipped.length ? { skipped } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
The readable compilation
|
||||
============================================================ */
|
||||
|
||||
const line = (label, value) => (value ? `- **${label}:** ${value}\n` : '');
|
||||
|
||||
function paletteTable(answers) {
|
||||
const rows = ROLES
|
||||
.map((role) => [role, String(answers[`palette-${role}`] || '')])
|
||||
.filter(([, hex]) => hex);
|
||||
if (!rows.length) return '';
|
||||
return `| Role | Value |\n| --- | --- |\n${rows.map(([role, hex]) => `| ${role} | \`${hex}\` |`).join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
function perSurfaceTable(answers, surfaces) {
|
||||
const rows = [];
|
||||
for (const key of PER_SURFACE) {
|
||||
for (const mode of surfaces) {
|
||||
const value = answers[`${key}-${mode}`];
|
||||
if (value) rows.push([key, SURFACE_LABELS[mode] || mode, String(value), answers[key] === value]);
|
||||
}
|
||||
}
|
||||
if (!rows.length) return '';
|
||||
return `| Question | Surface | Answer |\n| --- | --- | --- |\n${rows
|
||||
.map(([key, label, value, leads]) => `| ${key} | ${label}${leads ? ' (leads)' : ''} | ${value} |`)
|
||||
.join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
/** One document a reader, or another tool, can follow without this toolchain. */
|
||||
function renderMarkdown(bundle) {
|
||||
const context = bundle.context?.context || {};
|
||||
const answers = bundle.answers || {};
|
||||
const name = bundle.product?.name || 'This product';
|
||||
const surfaces = [].concat(answers['surface-modes'] || []).filter(Boolean);
|
||||
const out = [];
|
||||
|
||||
out.push(`# Design context: ${name}\n\n`);
|
||||
out.push('The decisions this product\'s design follows, and the reasoning behind them. ');
|
||||
out.push('Exported from Impeccable; treat it as the source of truth for visual and product direction.\n\n');
|
||||
|
||||
const audience = context.audience || {};
|
||||
if (Object.keys(audience).length) {
|
||||
out.push('## Audience\n\n');
|
||||
out.push(line('Primary', audience.primary));
|
||||
out.push(line('Secondary', audience.secondary));
|
||||
out.push(line('On arrival', audience.emotion));
|
||||
out.push(line('Leaving with', audience.leaving));
|
||||
if (audience.needs?.length) out.push(`- **Needs:** ${audience.needs.join('; ')}\n`);
|
||||
if (audience.trust?.length) out.push(`- **Trust triggers:** ${audience.trust.join('; ')}\n`);
|
||||
if (audience.inclusion?.length) out.push(`- **Must not exclude:** ${audience.inclusion.join('; ')}\n`);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
const product = context.product || {};
|
||||
if (Object.keys(product).length) {
|
||||
out.push('## Product\n\n');
|
||||
out.push(line('Purpose', product.purpose));
|
||||
out.push(line('Success', product.success));
|
||||
out.push(line('Platform', product.platform));
|
||||
out.push(line('Primary conversion', product.conversion));
|
||||
if (product.positioning?.not) out.push(`- **Not this:** ${product.positioning.not}\n`);
|
||||
if (product.positioning?.this) out.push(`- **This:** ${product.positioning.this}\n`);
|
||||
if (product.clarities?.length) out.push(`- **Clear first:** ${product.clarities.join('; ')}\n`);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
const brand = context.brand || {};
|
||||
if (Object.keys(brand).length) {
|
||||
out.push('## Brand\n\n');
|
||||
if (brand.words?.length) out.push(line('Words', brand.words.join(', ')));
|
||||
out.push(line('Personality', brand.personality));
|
||||
if (brand.commitments?.length) out.push(`- **Commitments:** ${brand.commitments.join('; ')}\n`);
|
||||
if (brand.voice?.length) {
|
||||
out.push('\nVoice, as wording rather than adjectives:\n\n');
|
||||
for (const pair of brand.voice) {
|
||||
if (pair?.say && pair?.not) out.push(`- Say: ${pair.say}\n Not: ${pair.not}\n`);
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
const interview = context.interview || {};
|
||||
if (interview.references?.length || interview.antiReference) {
|
||||
out.push('## References\n\n');
|
||||
for (const reference of interview.references || []) {
|
||||
if (typeof reference === 'string') out.push(`- ${reference}\n`);
|
||||
else if (reference?.name) out.push(`- **${reference.name}**${reference.takeaway ? `: ${reference.takeaway}` : ''}\n`);
|
||||
}
|
||||
const anti = interview.antiReference;
|
||||
if (typeof anti === 'string') out.push(`- **Anti-reference:** ${anti}\n`);
|
||||
else if (anti?.name) out.push(`- **Anti-reference:** ${anti.name}${anti.why ? ` (${anti.why})` : ''}\n`);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
out.push('## Decisions\n\n');
|
||||
if (surfaces.length) {
|
||||
out.push(`Surfaces: ${surfaces.map((mode) => SURFACE_LABELS[mode] || mode).join(', ')}. `);
|
||||
out.push('The first of these owns any answer stated once for the whole product.\n\n');
|
||||
}
|
||||
out.push('### Palette\n\n');
|
||||
out.push(paletteTable(answers));
|
||||
if (bundle.chosenCue?.slug) out.push(`Sampled from the generated cue \`${bundle.chosenCue.slug}\`.\n\n`);
|
||||
|
||||
out.push('### Typography\n\n');
|
||||
out.push(line('Heading', answers['font-heading']));
|
||||
out.push(line('Body', answers['font-body']));
|
||||
out.push(line('Type scale', answers['type-scale'] && `${answers['type-scale']} (${answers['type-scale-ratio']})`));
|
||||
out.push('\n');
|
||||
|
||||
if (answers['icon-pack-name']) {
|
||||
out.push('### Icons\n\n');
|
||||
out.push(`- **Pack:** ${answers['icon-pack-name']}${answers['icon-pack-license'] ? ` (${answers['icon-pack-license']})` : ''}\n`);
|
||||
if (answers['icon-pack-url']) out.push(`- **Source:** ${answers['icon-pack-url']}\n`);
|
||||
out.push('\nEvery icon comes from this pack; do not mix sets.\n\n');
|
||||
}
|
||||
|
||||
const perSurface = perSurfaceTable(answers, surfaces);
|
||||
if (perSurface) {
|
||||
out.push('### Per surface\n\n');
|
||||
out.push(perSurface);
|
||||
}
|
||||
if (answers['layout-structure']) out.push(`Composition: ${answers['layout-structure']}, one answer for the whole product.\n\n`);
|
||||
|
||||
if (bundle.designMd) {
|
||||
out.push('## DESIGN.md\n\n');
|
||||
out.push('The design document this context produced, verbatim.\n\n');
|
||||
out.push('<!-- begin DESIGN.md -->\n\n');
|
||||
out.push(bundle.designMd.trim());
|
||||
out.push('\n\n<!-- end DESIGN.md -->\n');
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
export async function exportDesignContext(cwd, { outDir, includeAssets = true, now } = {}) {
|
||||
const bundle = await buildBundle(cwd, { includeAssets, now });
|
||||
const destination = outDir ? path.resolve(cwd, outDir) : paths(cwd).exportsDir;
|
||||
await mkdir(destination, { recursive: true });
|
||||
|
||||
const markdownPath = path.join(destination, 'design-context.md');
|
||||
const bundlePath = path.join(destination, 'design-context.bundle.json');
|
||||
await writeFile(markdownPath, renderMarkdown(bundle));
|
||||
await writeJsonAtomic(bundlePath, bundle);
|
||||
return { markdownPath, bundlePath, skipped: bundle.skipped || [] };
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Import
|
||||
============================================================ */
|
||||
|
||||
export function validateBundle(bundle) {
|
||||
if (!bundle || typeof bundle !== 'object') throw new Error('That file is not a design context bundle');
|
||||
if (bundle.kind !== BUNDLE_KIND) throw new Error(`Expected a ${BUNDLE_KIND} bundle, found ${String(bundle.kind)}`);
|
||||
if (bundle.schemaVersion !== BUNDLE_SCHEMA) {
|
||||
throw new Error(`This bundle is schema version ${String(bundle.schemaVersion)}; this release reads ${BUNDLE_SCHEMA}. Update impeccable.`);
|
||||
}
|
||||
if (!bundle.answers || typeof bundle.answers !== 'object') throw new Error('The bundle carries no answers');
|
||||
return bundle;
|
||||
}
|
||||
|
||||
export async function importDesignContext(cwd, bundle, { design = 'skip' } = {}) {
|
||||
validateBundle(bundle);
|
||||
const target = paths(cwd);
|
||||
|
||||
await writeAnswers(bundle.answers, cwd);
|
||||
const context = bundle.context && typeof bundle.context === 'object'
|
||||
? bundle.context
|
||||
: { schemaVersion: SCHEMA_VERSION };
|
||||
await writeContext(context, cwd);
|
||||
|
||||
let written = 0;
|
||||
for (const file of Array.isArray(bundle.files) ? bundle.files : []) {
|
||||
const relative = String(file?.path || '');
|
||||
/* Containment is not enough on its own: a bundle could otherwise name a
|
||||
store file and overwrite what was just written. Only the three places an
|
||||
export puts bytes are accepted. */
|
||||
if (!ALLOWED_FILE.test(relative)) {
|
||||
process.stderr.write(`Skipped ${relative || '(unnamed)'}: not a place a design context keeps files\n`);
|
||||
continue;
|
||||
}
|
||||
const absolute = path.resolve(target.storeDir, relative);
|
||||
if (path.relative(target.storeDir, absolute).startsWith('..')) continue;
|
||||
await mkdir(path.dirname(absolute), { recursive: true });
|
||||
await writeFile(absolute, Buffer.from(String(file.base64 || ''), 'base64'));
|
||||
written += 1;
|
||||
}
|
||||
|
||||
/* The questionnaire cannot run without a cue manifest: its palette screen
|
||||
loads the deck and the built-in seeds together, and neither arrives if the
|
||||
file is missing. An imported project gets a valid one either way, carrying
|
||||
the chosen cue's dealt values when the bundle brought them. */
|
||||
if (!(await readJsonSoft(target.cuesJson))) {
|
||||
await writeJsonAtomic(target.cuesJson, {
|
||||
cues: [],
|
||||
...(bundle.chosenCue?.slug ? { palette: { [bundle.chosenCue.slug]: bundle.chosenCue.palette } } : { palette: {} }),
|
||||
});
|
||||
}
|
||||
if (bundle.fonts && !(await readJsonSoft(target.fontsManifestJson))) {
|
||||
await writeJsonAtomic(target.fontsManifestJson, bundle.fonts);
|
||||
}
|
||||
|
||||
let designWritten = false;
|
||||
if (design === 'write' && typeof bundle.designMd === 'string' && bundle.designMd.trim()) {
|
||||
const designPath = path.resolve(cwd, 'DESIGN.md');
|
||||
if (!(await readFile(designPath, 'utf8').then(() => true).catch(() => false))) {
|
||||
await writeFile(designPath, bundle.designMd);
|
||||
designWritten = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { written, designWritten, designCarried: typeof bundle.designMd === 'string' && Boolean(bundle.designMd.trim()) };
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
/** The save flow behind the design context document.
|
||||
*
|
||||
* A person edits fields in the document; the edits stage in the browser and
|
||||
* arrive here as one batch when they press Apply. Applying is deterministic:
|
||||
* every change names a binding, the binding names a file and a path, and the
|
||||
* value is written through the store. Nothing is searched for and no model is
|
||||
* involved, which is what makes a save either complete or refused rather than
|
||||
* approximately done.
|
||||
*
|
||||
* What the agent gets afterwards is the reconciliation, not the write. The
|
||||
* values are already on disk by the time the batch reaches a poll; DESIGN.md
|
||||
* and PRODUCT.md are the agent's to bring in line with them.
|
||||
*
|
||||
* browser --POST /doc/save-------> applied here, journaled, batch queued
|
||||
* agent --GET /doc/poll-------> save_batch (leased)
|
||||
* agent --POST /doc/reply------> acknowledged, version bumped
|
||||
* browser --GET /doc/state------> version moved, so re-read and re-render
|
||||
*
|
||||
* The batch is journaled before it is offered and cleared only on an
|
||||
* acknowledgement, so a session that dies mid-flight re-offers it on the next
|
||||
* boot rather than losing the work.
|
||||
*/
|
||||
|
||||
import { bindingFor, readPath, sanitizeValue, writePath } from './bindings.mjs';
|
||||
import {
|
||||
appendJournal,
|
||||
readAnswers,
|
||||
readContext,
|
||||
replayJournal,
|
||||
writeAnswers,
|
||||
writeContext,
|
||||
SCHEMA_VERSION,
|
||||
} from './store.mjs';
|
||||
|
||||
const MAX_CHANGES = 100;
|
||||
/* Long enough that an agent doing real prose work is never raced, short enough
|
||||
that an agent that died does not hold the batch for the session's lifetime. */
|
||||
const LEASE_MS = 10 * 60_000;
|
||||
|
||||
function httpError(statusCode, message) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function createSaveRoutes({ cwd = process.cwd(), onChange = () => {} } = {}) {
|
||||
/* Recovered from the journal at boot: a batch the agent never acknowledged
|
||||
is still owed, whoever was running when it was made. */
|
||||
const replayed = replayJournal(cwd);
|
||||
let pending = replayed.pendingBatch
|
||||
? { ...replayed.pendingBatch, leaseUntil: 0 }
|
||||
: null;
|
||||
let counter = Number(replayed.lastSeq) || 0;
|
||||
|
||||
const summary = () => (pending
|
||||
? { id: pending.id, status: pending.status, count: pending.changes.length }
|
||||
: null);
|
||||
|
||||
function validate(body) {
|
||||
const changes = Array.isArray(body?.changes) ? body.changes : null;
|
||||
if (!changes?.length) throw httpError(400, 'changes must be a non-empty array');
|
||||
if (changes.length > MAX_CHANGES) throw httpError(400, `at most ${MAX_CHANGES} changes per save`);
|
||||
|
||||
return changes.map((change) => {
|
||||
const binding = bindingFor(String(change?.bindingId ?? ''));
|
||||
if (!binding) throw httpError(400, `Unknown field: ${String(change?.bindingId ?? '')}`);
|
||||
let value;
|
||||
try {
|
||||
value = sanitizeValue(binding, change.to);
|
||||
} catch (error) {
|
||||
throw httpError(400, `${change.bindingId}: ${error.message}`);
|
||||
}
|
||||
return {
|
||||
bindingId: String(change.bindingId),
|
||||
binding,
|
||||
from: typeof change.from === 'string' ? change.from : '',
|
||||
to: value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** One read and one write per file, so a save lands whole or not at all. */
|
||||
async function applyToStore(changes) {
|
||||
const files = new Map();
|
||||
const load = async (file) => {
|
||||
if (!files.has(file)) {
|
||||
files.set(file, file === 'answers'
|
||||
? (await readAnswers(cwd)) || {}
|
||||
: (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION });
|
||||
}
|
||||
return files.get(file);
|
||||
};
|
||||
|
||||
for (const change of changes) {
|
||||
const document = await load(change.binding.file);
|
||||
/* context.json wraps its payload, so a binding path addresses the
|
||||
context object rather than the file's own root. */
|
||||
const root = change.binding.file === 'context'
|
||||
? (document.context ??= {})
|
||||
: document;
|
||||
change.previous = String(readPath(root, change.binding.path) ?? '');
|
||||
writePath(root, change.binding.path, change.to);
|
||||
}
|
||||
|
||||
if (files.has('answers')) await writeAnswers(files.get('answers'), cwd);
|
||||
if (files.has('context')) await writeContext(files.get('context'), cwd);
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
hasPending: () => Boolean(pending),
|
||||
|
||||
/** POST /doc/save */
|
||||
async save(body) {
|
||||
if (pending) throw httpError(409, 'A save is already applying');
|
||||
const changes = validate(body);
|
||||
await applyToStore(changes);
|
||||
|
||||
for (const change of changes) {
|
||||
appendJournal({
|
||||
type: 'change',
|
||||
bindingId: change.bindingId,
|
||||
from: change.previous,
|
||||
to: change.to,
|
||||
}, cwd);
|
||||
}
|
||||
|
||||
counter += 1;
|
||||
const id = `batch-${String(counter).padStart(3, '0')}`;
|
||||
const recorded = changes.map(({ bindingId, previous, to, binding }) => ({
|
||||
bindingId,
|
||||
from: previous,
|
||||
to,
|
||||
downstream: binding.downstream,
|
||||
}));
|
||||
appendJournal({ type: 'batch', id, status: 'pending', changes: recorded }, cwd);
|
||||
pending = { id, status: 'pending', changes: recorded, leaseUntil: 0 };
|
||||
onChange();
|
||||
return { id, count: recorded.length };
|
||||
},
|
||||
|
||||
/** The event a polling agent is handed, or nothing when none is due. */
|
||||
takeBatchEvent(replyCommandFor) {
|
||||
if (!pending || pending.leaseUntil > Date.now()) return null;
|
||||
/* Stamped before anything awaits, so a second poll arriving in the same
|
||||
tick cannot be handed the same batch. */
|
||||
pending.leaseUntil = Date.now() + LEASE_MS;
|
||||
return {
|
||||
type: 'save_batch',
|
||||
id: pending.id,
|
||||
changes: pending.changes,
|
||||
downstream: pending.changes.filter((change) => change.downstream !== 'none'),
|
||||
replyCommand: replyCommandFor(pending.id),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /doc/reply for a batch.
|
||||
*
|
||||
* An unknown id keeps the lease and says which batch is actually owed, so
|
||||
* an agent that replied to the wrong thing can correct itself rather than
|
||||
* leaving the work stranded.
|
||||
*/
|
||||
async reply(body) {
|
||||
if (!pending) throw httpError(404, 'No save is waiting for a reply');
|
||||
if (body.id !== pending.id) {
|
||||
throw httpError(404, `Unknown save ${String(body.id)}; the one waiting is ${pending.id}`);
|
||||
}
|
||||
if (!['done', 'error', 'retry'].includes(body.status)) {
|
||||
throw httpError(400, 'status must be done, error, or retry');
|
||||
}
|
||||
|
||||
if (body.status === 'retry') {
|
||||
pending.leaseUntil = 0;
|
||||
onChange();
|
||||
return { ok: true, status: 'pending' };
|
||||
}
|
||||
|
||||
/* The agent's own follow-on writes ride here rather than going to the
|
||||
store directly, so this process stays the only writer while it runs. */
|
||||
const applied = await applyAgentUpdates(body, cwd);
|
||||
appendJournal({ type: 'batch', id: pending.id, status: body.status, message: String(body.message || '') }, cwd);
|
||||
pending = null;
|
||||
onChange();
|
||||
return { ok: true, status: body.status, applied };
|
||||
},
|
||||
|
||||
/** Values an agent attached to a request reply; batch replies apply
|
||||
theirs inside reply(). */
|
||||
applyAgentUpdates: (body) => applyAgentUpdates(body, cwd),
|
||||
|
||||
/** Journaled so the tab re-reads on a font or freeform request too. */
|
||||
noteRequest(id, status) {
|
||||
appendJournal({ type: 'request', id, status }, cwd);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Key-value updates an agent attaches to its reply.
|
||||
*
|
||||
* Answers keys are written as given, since the questionnaire's own vocabulary
|
||||
* is wider than the bound fields; context values go through their binding when
|
||||
* one exists, so the same rules apply to both writers.
|
||||
*/
|
||||
async function applyAgentUpdates(body, cwd) {
|
||||
const applied = { answers: 0, context: 0 };
|
||||
|
||||
if (body.answers && typeof body.answers === 'object' && !Array.isArray(body.answers)) {
|
||||
const answers = (await readAnswers(cwd)) || {};
|
||||
for (const [key, value] of Object.entries(body.answers)) {
|
||||
if (typeof value !== 'string' && !Array.isArray(value)) continue;
|
||||
answers[key] = value;
|
||||
applied.answers += 1;
|
||||
}
|
||||
if (applied.answers) await writeAnswers(answers, cwd);
|
||||
}
|
||||
|
||||
if (body.context && typeof body.context === 'object' && !Array.isArray(body.context)) {
|
||||
const stored = (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION };
|
||||
const root = (stored.context ??= {});
|
||||
for (const [dotted, value] of Object.entries(body.context)) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const binding = bindingFor(dotted);
|
||||
let next = value;
|
||||
if (binding) {
|
||||
try {
|
||||
next = sanitizeValue(binding, value);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
writePath(root, binding ? binding.path : dotted, next);
|
||||
applied.context += 1;
|
||||
}
|
||||
if (applied.context) await writeContext(stored, cwd);
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
/** The design-context store: the one place that knows where design context lives.
|
||||
*
|
||||
* Layout, under the project root:
|
||||
*
|
||||
* .impeccable/design-context/
|
||||
* context.json { schemaVersion, modes, context } the chat half of the interview
|
||||
* answers.json the questionnaire submission, flat FormData shape
|
||||
* assets/ brand files the user supplied
|
||||
* fonts/ font faces the user uploaded
|
||||
* cue.png the chosen hero, copied at submit so the document stands alone
|
||||
* runtime/ session.json, journal.jsonl, draft.json (gitignored)
|
||||
* exports/ design-context.md, design-context.bundle.json (gitignored)
|
||||
*
|
||||
* Two rules hold this together. Every write goes through writeJsonAtomic, so a
|
||||
* reader never sees a torn file. Every read comes off disk, so no process ever
|
||||
* answers from a copy the file has moved past.
|
||||
*
|
||||
* Zero dependencies beyond node: builtins, like every other picker script.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { readFile, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const STORE_DIR = '.impeccable/design-context';
|
||||
const WORKSPACE_DIR = '.impeccable/visual-cues';
|
||||
/* The shape of context.json. Bump only when the shape changes, never for a release. */
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
const LEGACY_DIR = '.impeccable/design-interview';
|
||||
const LEGACY_FONTS_PREFIX = `${LEGACY_DIR}/fonts/`;
|
||||
|
||||
export function paths(cwd = process.cwd()) {
|
||||
const store = path.resolve(cwd, STORE_DIR);
|
||||
const runtime = path.join(store, 'runtime');
|
||||
return {
|
||||
storeDir: store,
|
||||
contextJson: path.join(store, 'context.json'),
|
||||
answersJson: path.join(store, 'answers.json'),
|
||||
assetsDir: path.join(store, 'assets'),
|
||||
fontsDir: path.join(store, 'fonts'),
|
||||
cuePng: path.join(store, 'cue.png'),
|
||||
runtimeDir: runtime,
|
||||
sessionJson: path.join(runtime, 'session.json'),
|
||||
journalJsonl: path.join(runtime, 'journal.jsonl'),
|
||||
draftJson: path.join(runtime, 'draft.json'),
|
||||
exportsDir: path.join(store, 'exports'),
|
||||
cuesJson: path.resolve(cwd, WORKSPACE_DIR, 'cues.json'),
|
||||
fontsManifestJson: path.resolve(cwd, WORKSPACE_DIR, 'fonts.json'),
|
||||
};
|
||||
}
|
||||
|
||||
/** The project-relative path an uploaded font is reported by, and stored under. */
|
||||
export function fontRelativePath(name) {
|
||||
return path.join(STORE_DIR, 'fonts', name);
|
||||
}
|
||||
|
||||
export async function writeJsonAtomic(filePath, value) {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
const temporary = `${filePath}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`);
|
||||
await rename(temporary, filePath);
|
||||
}
|
||||
|
||||
export async function readJsonSoft(filePath) {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const readContext = (cwd = process.cwd()) => readJsonSoft(paths(cwd).contextJson);
|
||||
export const writeContext = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).contextJson, value);
|
||||
export const readAnswers = (cwd = process.cwd()) => readJsonSoft(paths(cwd).answersJson);
|
||||
export const writeAnswers = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).answersJson, value);
|
||||
export const readDraft = (cwd = process.cwd()) => readJsonSoft(paths(cwd).draftJson);
|
||||
export const writeDraft = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).draftJson, value);
|
||||
export const clearDraft = (cwd = process.cwd()) => rm(paths(cwd).draftJson, { force: true }).catch(() => {});
|
||||
|
||||
/* ============================================================
|
||||
The journal: append-only, replayed on every read.
|
||||
============================================================ */
|
||||
|
||||
/** Append one event, stamped with the next seq and a timestamp. Returns the seq. */
|
||||
export function appendJournal(event, cwd = process.cwd()) {
|
||||
const { runtimeDir, journalJsonl } = paths(cwd);
|
||||
const seq = replayJournal(cwd).lastSeq + 1;
|
||||
fs.mkdirSync(runtimeDir, { recursive: true });
|
||||
fs.appendFileSync(journalJsonl, `${JSON.stringify({ seq, ts: new Date().toISOString(), ...event })}\n`);
|
||||
return seq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the journal into the state a booting session needs.
|
||||
*
|
||||
* Lines the fold cannot use are collected rather than thrown: a legacy
|
||||
* doc-edits.jsonl record carries { at, type: 'color' } and no seq, and a torn
|
||||
* final line is possible after a hard kill. Neither can move lastSeq or
|
||||
* resurrect a batch, so both are diagnostics, not failures.
|
||||
*/
|
||||
export function replayJournal(cwd = process.cwd()) {
|
||||
const { journalJsonl } = paths(cwd);
|
||||
const state = { lastSeq: 0, pendingBatch: null, entries: [], diagnostics: [] };
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(journalJsonl, 'utf8');
|
||||
} catch {
|
||||
return state;
|
||||
}
|
||||
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
state.diagnostics.push({ reason: 'unparseable', line: line.slice(0, 200) });
|
||||
continue;
|
||||
}
|
||||
if (!entry || typeof entry !== 'object' || !Number.isInteger(entry.seq)) {
|
||||
state.diagnostics.push({ reason: 'legacy-or-unsequenced', type: entry?.type || null });
|
||||
continue;
|
||||
}
|
||||
state.entries.push(entry);
|
||||
if (entry.seq > state.lastSeq) state.lastSeq = entry.seq;
|
||||
if (entry.type === 'batch') {
|
||||
state.pendingBatch = entry.status === 'pending' ? entry : null;
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Migration from the pre-store layout.
|
||||
============================================================ */
|
||||
|
||||
export function pidAlive(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
/* EPERM means the process exists and is not ours to signal. */
|
||||
return error.code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFile(from, to) {
|
||||
if (fs.existsSync(to) || !fs.existsSync(from)) return false;
|
||||
await mkdir(path.dirname(to), { recursive: true });
|
||||
await rename(from, to);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Directories move child by child: renaming onto an existing directory fails,
|
||||
and a run interrupted halfway leaves a destination that already exists. */
|
||||
async function moveDirContents(fromDir, toDir) {
|
||||
if (!fs.existsSync(fromDir)) return;
|
||||
await mkdir(toDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(fromDir)) {
|
||||
await moveFile(path.join(fromDir, name), path.join(toDir, name));
|
||||
}
|
||||
try {
|
||||
if (fs.readdirSync(fromDir).length === 0) fs.rmdirSync(fromDir);
|
||||
} catch {
|
||||
/* Something arrived between the read and the remove; leaving it is safe. */
|
||||
}
|
||||
}
|
||||
|
||||
/** Uploaded-face paths were recorded as strings inside the answers themselves. */
|
||||
function rewriteFontSources(answers) {
|
||||
if (!answers || typeof answers !== 'object') return null;
|
||||
let touched = false;
|
||||
for (const [key, value] of Object.entries(answers)) {
|
||||
if (typeof value !== 'string' || !value.includes(LEGACY_FONTS_PREFIX)) continue;
|
||||
answers[key] = value.split(LEGACY_FONTS_PREFIX).join(`${STORE_DIR}/fonts/`);
|
||||
touched = true;
|
||||
}
|
||||
return touched ? answers : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a pre-store project onto the current layout. Idempotent and silent:
|
||||
* a project that is already current, or was never interviewed, does nothing.
|
||||
*
|
||||
* A live session of the old shape holds the old paths in its own constants, so
|
||||
* migrating under it would strand its writes. That case defers to the next boot.
|
||||
*/
|
||||
export async function migrate(cwd = process.cwd()) {
|
||||
const legacyDir = path.resolve(cwd, LEGACY_DIR);
|
||||
if (!fs.existsSync(legacyDir)) {
|
||||
await migrateContextFromCues(cwd);
|
||||
return { migrated: false, deferred: false };
|
||||
}
|
||||
|
||||
const legacySession = path.join(legacyDir, 'doc-session.json');
|
||||
const session = await readJsonSoft(legacySession);
|
||||
if (session && pidAlive(session.pid)) return { migrated: false, deferred: true };
|
||||
|
||||
const target = paths(cwd);
|
||||
await moveFile(path.join(legacyDir, 'answers.json'), target.answersJson);
|
||||
await moveFile(path.join(legacyDir, 'doc-edits.jsonl'), target.journalJsonl);
|
||||
await moveDirContents(path.join(legacyDir, 'assets'), target.assetsDir);
|
||||
await moveDirContents(path.join(legacyDir, 'fonts'), target.fontsDir);
|
||||
|
||||
const answers = await readJsonSoft(target.answersJson);
|
||||
const rewritten = rewriteFontSources(answers);
|
||||
if (rewritten) await writeJsonAtomic(target.answersJson, rewritten);
|
||||
|
||||
await rm(legacySession, { force: true }).catch(() => {});
|
||||
try {
|
||||
if (fs.readdirSync(legacyDir).length === 0) fs.rmdirSync(legacyDir);
|
||||
} catch {
|
||||
/* Files the migration does not own stay where they are. */
|
||||
}
|
||||
|
||||
await migrateContextFromCues(cwd);
|
||||
return { migrated: true, deferred: false };
|
||||
}
|
||||
|
||||
/* The chat half of the interview used to ride inside the cue manifest. It is
|
||||
not a generation artifact, so it moves to the store; cues.json keeps its
|
||||
cues and palette and is left untouched. */
|
||||
async function migrateContextFromCues(cwd) {
|
||||
const target = paths(cwd);
|
||||
if (fs.existsSync(target.contextJson)) return;
|
||||
const cues = await readJsonSoft(target.cuesJson);
|
||||
if (!cues) return;
|
||||
const hasModes = Array.isArray(cues.modes);
|
||||
const hasContext = cues.context && typeof cues.context === 'object';
|
||||
if (!hasModes && !hasContext) return;
|
||||
await writeJsonAtomic(target.contextJson, {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
...(hasModes ? { modes: cues.modes } : {}),
|
||||
...(hasContext ? { context: cues.context } : {}),
|
||||
});
|
||||
}
|
||||
@@ -18,13 +18,4 @@ if (!detectorPath) {
|
||||
|
||||
const { detectCli } = await import(pathToFileURL(detectorPath));
|
||||
|
||||
// A comp-led build with its comp round or hero gate still open is not a page
|
||||
// the detector can pass: say so after the scan (stderr, so --json stays
|
||||
// parseable), on the same condition context.mjs reports at boot.
|
||||
try {
|
||||
const { compRoundOpen } = await import(pathToFileURL(path.join(__dirname, 'build-phase.mjs')));
|
||||
const open = compRoundOpen(process.cwd());
|
||||
if (open) process.stderr.write(`COMP_ROUND_OPEN: ${open.reason}. A detector pass is not a finish: run node ${__dirname}/build-phase.mjs status and follow its NEXT line before treating this page as built.\n`);
|
||||
} catch { /* build-phase absent */ }
|
||||
|
||||
await detectCli();
|
||||
|
||||
@@ -1472,19 +1472,7 @@ if (IS_BROWSER) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
// A page matched by detector.ignoreFiles is waived wholesale: every scan
|
||||
// stage answers empty so the badge and toast read zero. Mirrors
|
||||
// shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live
|
||||
// overlay resolves the globs per page (live-browser-ignores.js) and
|
||||
// forwards the verdict as config.skipScan.
|
||||
function skipScanActive() {
|
||||
return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true;
|
||||
}
|
||||
|
||||
function collectBrowserFindings() {
|
||||
if (skipScanActive()) {
|
||||
return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] };
|
||||
}
|
||||
const groupMap = new Map();
|
||||
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
|
||||
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
|
||||
@@ -1687,119 +1675,6 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, mapped);
|
||||
}
|
||||
|
||||
// Value-level suppression (issue #639). `disabledRules` above handles
|
||||
// whole rules; this applies the config's remaining ignoreValues entries,
|
||||
// which the CLI filters through isIgnoredFindingValue in
|
||||
// cli/lib/impeccable-config.mjs, so a project waiver like
|
||||
// overused-font = "geist mono" reaches the overlay and extension too.
|
||||
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
|
||||
const _disabledValues = EXTENSION_MODE
|
||||
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
|
||||
.filter(e => e && typeof e === 'object' && e.rule && e.value)
|
||||
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
|
||||
: [];
|
||||
if (_disabledValues.length > 0) {
|
||||
// The six rules whose findings carry a matchable value; keep in step
|
||||
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
|
||||
// Everything else is suppressed by rule or by file scope, both already
|
||||
// resolved into disabledRules before the scan message was sent.
|
||||
const _directValueRules = new Set([
|
||||
'overused-font',
|
||||
'bounce-easing',
|
||||
'design-system-font',
|
||||
'design-system-color',
|
||||
'design-system-radius',
|
||||
'design-system-font-size',
|
||||
]);
|
||||
// The design-system checks set `ignoreValue` on their findings; the
|
||||
// detail fallbacks catch overused-font, whose value lives in its
|
||||
// sentence. One CLI matcher is not mirrored here: the motion extractor
|
||||
// (a value-scoped bounce-easing waiver only matches when the finding
|
||||
// carries ignoreValue directly). The CLI's [?&]family= URL fallback is
|
||||
// also omitted on purpose: browser findings for these rules always
|
||||
// carry ignoreValue or a "Primary font:" / "Google Fonts:" /
|
||||
// font-family sentence, so it is unreachable here.
|
||||
const _findingValue = (f) => {
|
||||
if (!f || !_directValueRules.has(f.type || f.id)) return '';
|
||||
const direct = f.ignoreValue || f.value;
|
||||
if (direct) return _normValue(direct);
|
||||
// The CLI routes bounce-easing through extractMotionIgnoreValue and
|
||||
// never the font regexes; without a direct ignoreValue there is no
|
||||
// value to match, so do not invent one from unrelated CSS text.
|
||||
if ((f.type || f.id) === 'bounce-easing') return '';
|
||||
for (const text of [f.detail, f.snippet]) {
|
||||
if (typeof text !== 'string' || !text) continue;
|
||||
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
||||
if (primary) return _normValue(primary[1]);
|
||||
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
|
||||
if (google) return _normValue(google[1]);
|
||||
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
||||
if (family) return _normValue(family[1]);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
// design-system-color compares by color value, not by spelling: the
|
||||
// browser reports computed rgb(...) strings while waivers are usually
|
||||
// written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in
|
||||
// cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms;
|
||||
// hsl stays CLI-only.
|
||||
const _colorKey = (value) => {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
|
||||
if (hex) {
|
||||
const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1];
|
||||
const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16));
|
||||
return `${r},${g},${b},${a}`;
|
||||
}
|
||||
const rgb = text.match(/^rgba?\((.*)\)$/);
|
||||
if (!rgb) return '';
|
||||
const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / ');
|
||||
let parts;
|
||||
if (body.includes(',')) {
|
||||
parts = body.split(',').map(p => p.trim()).filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.includes('/')) {
|
||||
parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)];
|
||||
}
|
||||
} else {
|
||||
parts = body.split(/\s+/).filter(p => p && p !== '/');
|
||||
}
|
||||
if (parts.length < 3 || parts.length > 4) return '';
|
||||
const channel = (raw, isAlpha) => {
|
||||
const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!m) return null;
|
||||
let v = parseFloat(m[1]);
|
||||
if (m[2]) v = isAlpha ? v / 100 : v * 2.55;
|
||||
const max = isAlpha ? 1 : 255;
|
||||
if (!Number.isFinite(v) || v < 0 || v > max) return null;
|
||||
return isAlpha ? v : Math.round(v);
|
||||
};
|
||||
const r = channel(parts[0], false);
|
||||
const g = channel(parts[1], false);
|
||||
const b = channel(parts[2], false);
|
||||
const a = parts[3] === undefined ? 1 : channel(parts[3], true);
|
||||
if ([r, g, b, a].some(v => v === null)) return '';
|
||||
return `${r},${g},${b},${Math.round(a * 255)}`;
|
||||
};
|
||||
const _valueIgnored = (f) => {
|
||||
const value = _findingValue(f);
|
||||
if (!value) return false;
|
||||
const rule = f.type || f.id;
|
||||
return _disabledValues.some(e => e.rule === rule && (e.value === value
|
||||
|| (rule === 'design-system-color'
|
||||
&& _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(value))));
|
||||
};
|
||||
for (const [el, list] of [...groupMap.entries()]) {
|
||||
const kept = list.filter(f => !_valueIgnored(f));
|
||||
if (kept.length > 0) groupMap.set(el, kept);
|
||||
else groupMap.delete(el);
|
||||
}
|
||||
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
|
||||
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groupMap,
|
||||
allFindings: browserFindingsFromMap(groupMap),
|
||||
@@ -2017,12 +1892,6 @@ if (IS_BROWSER) {
|
||||
|
||||
async function collectBrowserFindingsAsync(options = {}, runtime = {}) {
|
||||
const collected = collectBrowserFindings();
|
||||
// The visual pass walks the DOM on its own; on a skipScan page it would
|
||||
// repopulate the emptied scan, so it is skipped with everything else.
|
||||
if (skipScanActive()) {
|
||||
lastVisualContrastAnalyses = [];
|
||||
return { ...collected, allFindings: [], visualContrastAnalyses: [] };
|
||||
}
|
||||
await addVisualContrastFindings(collected.groupMap, options, runtime);
|
||||
return {
|
||||
...collected,
|
||||
@@ -2076,7 +1945,7 @@ if (IS_BROWSER) {
|
||||
const generation = scanGeneration;
|
||||
const collected = collectBrowserFindings();
|
||||
const allFindings = renderBrowserFindings(collected, options);
|
||||
if (!skipScanActive() && shouldRunVisualContrast(options)) {
|
||||
if (shouldRunVisualContrast(options)) {
|
||||
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
|
||||
.then(() => {
|
||||
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
|
||||
|
||||
@@ -995,9 +995,7 @@ function extractRadiusTokens(value) {
|
||||
return String(value || '')
|
||||
.replace(/\s*\/\s*/g, ' ')
|
||||
.split(/\s+/)
|
||||
// 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(/\)+$/, ''))
|
||||
.map(token => token.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,27 +70,13 @@ function isBrandFontOnOwnDomain(font) {
|
||||
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||
}
|
||||
|
||||
// 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',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
...CSS_GENERIC_FONTS,
|
||||
'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',
|
||||
]);
|
||||
|
||||
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);
|
||||
@@ -246,24 +232,6 @@ const ANTIPATTERNS = [
|
||||
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
|
||||
skillSection: 'Imagery',
|
||||
},
|
||||
{
|
||||
id: 'organic-clip-path',
|
||||
category: 'quality',
|
||||
name: 'Organic contour drawn as clip-path',
|
||||
description:
|
||||
'A clip-path polygon with many arbitrary vertices, or a curved clip-path path(), is CSS approximating a torn edge, blob, or silhouette. It reads as the cheap version of the effect and is usually a produced or photographic material replaced with code. Derive an alpha matte from the real image, or ship the shape as a cut-out raster; keep clip-path for geometry (cut corners, diagonals, hexagons).',
|
||||
skillSection: 'Imagery',
|
||||
skillGuideline: 'geometric masks standing in for organic contours',
|
||||
},
|
||||
{
|
||||
id: 'buried-raster',
|
||||
category: 'quality',
|
||||
name: 'Raster buried under a wash or opacity',
|
||||
description:
|
||||
'A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.',
|
||||
skillSection: 'Imagery',
|
||||
skillGuideline: 'a produced material must survive to the screen',
|
||||
},
|
||||
{
|
||||
id: 'dark-glow',
|
||||
category: 'slop',
|
||||
@@ -1605,7 +1573,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 = primaryFontFace(fontFamily, GENERIC_FONTS);
|
||||
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
|
||||
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 };
|
||||
@@ -1956,13 +1924,8 @@ function enclosingCssSelector(cssText, index) {
|
||||
if (!cssText || !Number.isFinite(index)) return null;
|
||||
const open = cssText.lastIndexOf('{', index);
|
||||
if (open === -1) return null;
|
||||
// A match inside an inline style fragment (`style="…"` appended to the
|
||||
// corpus by buildHtmlPatternCorpora) has no enclosing rule; the previous
|
||||
// `{` belongs to some other selector.
|
||||
const closeBeforeIndex = cssText.lastIndexOf('}', index);
|
||||
if (closeBeforeIndex > open) return null;
|
||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
||||
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
|
||||
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
|
||||
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
|
||||
// Keyframe steps: percentage steps fail the digit test above, but `from`
|
||||
// and `to` would read as (never-matching) type selectors and get a valid
|
||||
@@ -2728,95 +2691,6 @@ function scanHtmlForShapeAssembledIllustration(html) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
|
||||
// --- Organic clip-path polygons ----------------------------------------------
|
||||
// A `clip-path: polygon(...)` with many vertices, or `clip-path: path(...)`
|
||||
// with curves, is CSS approximating an organic contour: a torn edge, a blob,
|
||||
// a silhouette. The approximation reads as the cheap version of the effect
|
||||
// (the craft floor's geometric-occlusion-mask ban), and it is the signature
|
||||
// of a comp's produced material being replaced with code. Geometric clips
|
||||
// (cut corners, diagonals, hexagons, arrows: few vertices, or vertices on
|
||||
// the 0/50/100 grid) pass; circle()/inset()/ellipse() pass; a mask-image
|
||||
// from an alpha matte passes.
|
||||
const ORGANIC_POLYGON_MIN_VERTICES = 10;
|
||||
function scanCssTextForOrganicClipPath(styleText) {
|
||||
const findings = [];
|
||||
const re = /clip-path\s*:\s*(polygon|path)\s*\(([^)]*(?:\)[^;}]*)?)/gi;
|
||||
let m;
|
||||
while ((m = re.exec(styleText)) !== null) {
|
||||
const kind = m[1].toLowerCase();
|
||||
const body = m[2];
|
||||
if (kind === 'path') {
|
||||
// curves (C, S, Q, T, A, absolute or relative) drawing a contour, not a
|
||||
// rectilinear M/L/Z outline; letters in path data are only commands
|
||||
const curves = (body.match(/[CSQTA]/gi) || []).length;
|
||||
if (curves < 3) continue;
|
||||
findings.push({ id: 'organic-clip-path', snippet: `clip-path: path() with ${curves} curve segments`, selector: enclosingCssSelector(styleText, m.index) || undefined });
|
||||
continue;
|
||||
}
|
||||
const points = body.split(',').map((p) => p.trim()).filter(Boolean);
|
||||
if (points.length < ORGANIC_POLYGON_MIN_VERTICES) continue;
|
||||
// Vertices sitting on a coarse grid (multiples of 25%) are geometric; a
|
||||
// contour has arbitrary values.
|
||||
let offGrid = 0;
|
||||
for (const p of points) {
|
||||
const nums = p.match(/-?[\d.]+/g) || [];
|
||||
for (const n of nums) { const v = parseFloat(n); if (Math.abs(v - Math.round(v / 25) * 25) > 0.5) offGrid++; }
|
||||
}
|
||||
if (offGrid < points.length) continue;
|
||||
findings.push({ id: 'organic-clip-path', snippet: `clip-path: polygon() with ${points.length} vertices approximating an organic contour`, selector: enclosingCssSelector(styleText, m.index) || undefined });
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// --- Buried raster ------------------------------------------------------------
|
||||
// A raster (background-image url or <img>) that never reaches the screen:
|
||||
// under a near-opaque gradient wash in the same background stack, or on an
|
||||
// element at near-zero opacity. It is how a produced texture "ships" while
|
||||
// the page shows flat color, and the finish reviewer cannot see it either.
|
||||
// A tint under 0.9 alpha passes (hero darkening); a blend mode passes
|
||||
// (multiply/overlay keep the material visible); opacity >= 0.15 passes.
|
||||
function scanCssTextForBuriedRaster(styleText) {
|
||||
const findings = [];
|
||||
// background stacks: split declarations, look for url() + a gradient whose
|
||||
// stops all carry alpha >= 0.9 (or opaque hex/named colors)
|
||||
const declRe = /background(?:-image)?\s*:\s*([^;}]+)/gi;
|
||||
let m;
|
||||
while ((m = declRe.exec(styleText)) !== null) {
|
||||
const value = m[1];
|
||||
if (!/url\(/i.test(value) || !/gradient\(/i.test(value)) continue;
|
||||
// a blend mode declared in the same rule keeps the raster visible
|
||||
const ruleStart = styleText.lastIndexOf('{', m.index);
|
||||
const ruleEnd = styleText.indexOf('}', m.index);
|
||||
const rule = styleText.slice(ruleStart < 0 ? 0 : ruleStart, ruleEnd < 0 ? styleText.length : ruleEnd);
|
||||
if (/background-blend-mode\s*:\s*(?!normal)/i.test(rule) || /mix-blend-mode\s*:\s*(?!normal)/i.test(rule)) continue;
|
||||
// Layers are painted first-on-top: only a wash listed BEFORE the url()
|
||||
// covers it. An image on top of a gradient is not buried.
|
||||
const firstUrl = value.search(/url\(/i);
|
||||
const gradients = [...value.matchAll(/(?:linear|radial|conic)-gradient\([^()]*(?:\([^()]*\)[^()]*)*\)/gi)].filter((gm) => gm.index < firstUrl).map((gm) => gm[0]);
|
||||
let opaqueWash = false;
|
||||
// an alpha token normalized to 0..1: '0.8' -> 0.8, '80%' -> 0.8
|
||||
const alphaOf = (a) => { if (a == null) return 1; const v = parseFloat(a); return String(a).trim().endsWith('%') ? v / 100 : v; };
|
||||
for (const g of gradients) {
|
||||
const alphas = [...g.matchAll(/rgba?\(\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*(?:[,/]\s*([\d.]+%?))?\s*\)|hsla?\([^)]*?(?:[,/]\s*([\d.]+%?))?\s*\)/gi)].map((a) => alphaOf(a[1] ?? a[2]));
|
||||
const stripped = g.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)/gi, '');
|
||||
// hex stops: 4- and 8-digit forms carry their own alpha
|
||||
for (const h of stripped.matchAll(/#([0-9a-f]{3,8})\b/gi)) {
|
||||
const hex = h[1];
|
||||
if (hex.length === 4) alphas.push(parseInt(hex[3] + hex[3], 16) / 255);
|
||||
else if (hex.length === 8) alphas.push(parseInt(hex.slice(6), 16) / 255);
|
||||
else alphas.push(1);
|
||||
}
|
||||
const named = /\b(?:white|black|ivory|beige|linen|snow|cream)\b/i.test(stripped);
|
||||
if (named) alphas.push(1);
|
||||
if (alphas.length && alphas.every((a) => !Number.isFinite(a) || a >= 0.9)) { opaqueWash = true; break; }
|
||||
}
|
||||
if (!opaqueWash) continue;
|
||||
findings.push({ id: 'buried-raster', snippet: `raster under a near-opaque gradient wash: ${value.trim().slice(0, 90)}`, selector: enclosingCssSelector(styleText, m.index) || undefined });
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Scoped scan corpora for the page-level pattern checks. CSS-property
|
||||
// regexes run over the whole source string fire on documentation ABOUT
|
||||
// css — `<code>background-clip: text</code>` prose, <pre> samples, HTML
|
||||
@@ -2989,10 +2863,6 @@ function checkHtmlPatterns(html, corpora) {
|
||||
// Shape-assembled illustrations (large pictorial SVGs built from primitives)
|
||||
findings.push(...scanHtmlForShapeAssembledIllustration(html));
|
||||
|
||||
// Organic clip-path contours and rasters buried under washes or opacity
|
||||
findings.push(...scanCssTextForOrganicClipPath(styleText));
|
||||
findings.push(...scanCssTextForBuriedRaster(styleText));
|
||||
|
||||
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
|
||||
findings.push(...scanCssTextForMarquee(styleText, html));
|
||||
|
||||
@@ -4463,30 +4333,14 @@ function isNonRenderedText(el, tag, style) {
|
||||
function checkQuality(opts) {
|
||||
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
|
||||
const findings = [];
|
||||
// A raster (<img>, or an element with a background url) at near-zero
|
||||
// opacity never reaches the screen: the produced material ships as a
|
||||
// compliance token. The CSS-text scan catches the stylesheet form; this
|
||||
// catches computed opacity on the element itself (both engines).
|
||||
// Skip browser extension injected elements BEFORE any finding is pushed
|
||||
// (a low-opacity raster those hosts inject used to be recorded and then
|
||||
// returned by this very skip). Read the id via getAttribute whenever
|
||||
// `el.id` is not a string: on a <form> (and other [LegacyOverrideBuiltIns]
|
||||
// hosts) a named control like <input name="id"> shadows the builtin `id`
|
||||
// getter and returns the control element, whose `.startsWith` is undefined
|
||||
// and throws (issue #407 — every Shopify product form ships an
|
||||
// <input name="id">).
|
||||
// Skip browser extension injected elements. Read the id via getAttribute
|
||||
// whenever `el.id` is not a string: on a <form> (and other
|
||||
// [LegacyOverrideBuiltIns] hosts) a named control like <input name="id">
|
||||
// shadows the builtin `id` getter and returns the control element, whose
|
||||
// `.startsWith` is undefined and throws (issue #407 — every Shopify product
|
||||
// form ships an <input name="id">).
|
||||
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || '');
|
||||
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
|
||||
{
|
||||
const op = parseFloat(style.opacity);
|
||||
if (Number.isFinite(op) && op < 0.15 && op >= 0) {
|
||||
const bg = String(style.backgroundImage || '');
|
||||
if (tag === 'img' || /url\(/i.test(bg)) {
|
||||
const label = tag === 'img' ? (el.getAttribute && el.getAttribute('alt')) || '' : (el.textContent || '').trim().slice(0, 40);
|
||||
findings.push({ id: 'buried-raster', snippet: `${tag === 'img' ? '<img>' : 'raster background'} at opacity ${op}${label ? ` "${label}"` : ''}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Line length too long --- (browser-only: needs rect.width)
|
||||
if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
|
||||
@@ -5204,7 +5058,8 @@ function checkTypography() {
|
||||
const style = getComputedStyle(el);
|
||||
const ff = style.fontFamily;
|
||||
if (!ff) continue;
|
||||
const primary = primaryFontFace(ff);
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
if (!primary) continue;
|
||||
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
||||
totalTextElements++;
|
||||
@@ -5449,7 +5304,8 @@ function checkPageTypography(doc, win) {
|
||||
if (rule.type !== 1) continue;
|
||||
const ff = rule.style?.fontFamily;
|
||||
if (!ff) continue;
|
||||
const primary = primaryFontFace(ff);
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
@@ -5468,10 +5324,11 @@ function checkPageTypography(doc, win) {
|
||||
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
let fm;
|
||||
while ((fm = ffRe.exec(html)) !== null) {
|
||||
const primary = primaryFontFace(fm[1]);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6547,36 +6404,6 @@ function checkTextOcclusionDOM() {
|
||||
// reads as an opaque box.
|
||||
const effectiveOpacity = effectiveOpacityDOM;
|
||||
|
||||
// The part of an element that is actually painted, after every scrolling or
|
||||
// clipping ancestor has had its say.
|
||||
//
|
||||
// getBoundingClientRect reports where a box would be if nothing cut it off,
|
||||
// so a paragraph half scrolled out of a panel still reports its full height,
|
||||
// and the half that is clipped away lands wherever the page continues below
|
||||
// the panel. The elementFromPoint probe then samples coordinates the text is
|
||||
// not painted at, finds whatever genuinely is painted there, and reports the
|
||||
// text as buried under it. Any sticky footer or toolbar beneath a scroll
|
||||
// region produces this, and it is the shape most likely to be waved off as
|
||||
// noise, which costs the rule its credibility on the findings that are real.
|
||||
//
|
||||
// Border box rather than padding box on purpose: it errs toward probing, and
|
||||
// giving up a scrollbar gutter's width would drop true findings at the right
|
||||
// edge of a scroller.
|
||||
const paintedRect = (el, rect) => {
|
||||
let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
|
||||
for (let cur = el.parentElement; cur && cur !== document.documentElement; cur = cur.parentElement) {
|
||||
let cs; try { cs = getComputedStyle(cur); } catch { continue; }
|
||||
const clipsX = String(cs.overflowX || 'visible') !== 'visible';
|
||||
const clipsY = String(cs.overflowY || 'visible') !== 'visible';
|
||||
if (!clipsX && !clipsY) continue;
|
||||
let b; try { b = cur.getBoundingClientRect(); } catch { continue; }
|
||||
if (clipsX) { left = Math.max(left, b.left); right = Math.min(right, b.right); }
|
||||
if (clipsY) { top = Math.max(top, b.top); bottom = Math.min(bottom, b.bottom); }
|
||||
if (right - left < 1 || bottom - top < 1) return null;
|
||||
}
|
||||
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
||||
};
|
||||
|
||||
// Collect renderable text owners in / near the first viewport for the
|
||||
// elementFromPoint probe. SVG <text> counts too.
|
||||
const textEls = [];
|
||||
@@ -6589,13 +6416,8 @@ function checkTextOcclusionDOM() {
|
||||
if (text.length < 2) continue;
|
||||
if (!isPaintedForOcclusion(el)) continue;
|
||||
if (effectiveOpacity(el) <= 0.02) continue;
|
||||
let full; try { full = el.getBoundingClientRect(); } catch { continue; }
|
||||
if (full.width < 6 || full.height < 6) continue;
|
||||
// Probe only where the text is on screen. A run clipped down to a sliver is
|
||||
// dropped rather than sampled: a few pixels of visible text cannot support
|
||||
// a coverage fraction worth reporting either way.
|
||||
const rect = paintedRect(el, full);
|
||||
if (!rect || rect.width < 6 || rect.height < 6) continue;
|
||||
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
|
||||
if (rect.width < 6 || rect.height < 6) continue;
|
||||
// Viewport-bound probe: keep text whose box overlaps the live viewport.
|
||||
if (rect.bottom <= 0 || rect.top >= vh) continue;
|
||||
textEls.push({ el, rect, text, inSvg });
|
||||
@@ -8309,19 +8131,7 @@ if (IS_BROWSER) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
// A page matched by detector.ignoreFiles is waived wholesale: every scan
|
||||
// stage answers empty so the badge and toast read zero. Mirrors
|
||||
// shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live
|
||||
// overlay resolves the globs per page (live-browser-ignores.js) and
|
||||
// forwards the verdict as config.skipScan.
|
||||
function skipScanActive() {
|
||||
return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true;
|
||||
}
|
||||
|
||||
function collectBrowserFindings() {
|
||||
if (skipScanActive()) {
|
||||
return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] };
|
||||
}
|
||||
const groupMap = new Map();
|
||||
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
|
||||
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
|
||||
@@ -8524,119 +8334,6 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, mapped);
|
||||
}
|
||||
|
||||
// Value-level suppression (issue #639). `disabledRules` above handles
|
||||
// whole rules; this applies the config's remaining ignoreValues entries,
|
||||
// which the CLI filters through isIgnoredFindingValue in
|
||||
// cli/lib/impeccable-config.mjs, so a project waiver like
|
||||
// overused-font = "geist mono" reaches the overlay and extension too.
|
||||
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
|
||||
const _disabledValues = EXTENSION_MODE
|
||||
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
|
||||
.filter(e => e && typeof e === 'object' && e.rule && e.value)
|
||||
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
|
||||
: [];
|
||||
if (_disabledValues.length > 0) {
|
||||
// The six rules whose findings carry a matchable value; keep in step
|
||||
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
|
||||
// Everything else is suppressed by rule or by file scope, both already
|
||||
// resolved into disabledRules before the scan message was sent.
|
||||
const _directValueRules = new Set([
|
||||
'overused-font',
|
||||
'bounce-easing',
|
||||
'design-system-font',
|
||||
'design-system-color',
|
||||
'design-system-radius',
|
||||
'design-system-font-size',
|
||||
]);
|
||||
// The design-system checks set `ignoreValue` on their findings; the
|
||||
// detail fallbacks catch overused-font, whose value lives in its
|
||||
// sentence. One CLI matcher is not mirrored here: the motion extractor
|
||||
// (a value-scoped bounce-easing waiver only matches when the finding
|
||||
// carries ignoreValue directly). The CLI's [?&]family= URL fallback is
|
||||
// also omitted on purpose: browser findings for these rules always
|
||||
// carry ignoreValue or a "Primary font:" / "Google Fonts:" /
|
||||
// font-family sentence, so it is unreachable here.
|
||||
const _findingValue = (f) => {
|
||||
if (!f || !_directValueRules.has(f.type || f.id)) return '';
|
||||
const direct = f.ignoreValue || f.value;
|
||||
if (direct) return _normValue(direct);
|
||||
// The CLI routes bounce-easing through extractMotionIgnoreValue and
|
||||
// never the font regexes; without a direct ignoreValue there is no
|
||||
// value to match, so do not invent one from unrelated CSS text.
|
||||
if ((f.type || f.id) === 'bounce-easing') return '';
|
||||
for (const text of [f.detail, f.snippet]) {
|
||||
if (typeof text !== 'string' || !text) continue;
|
||||
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
||||
if (primary) return _normValue(primary[1]);
|
||||
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
|
||||
if (google) return _normValue(google[1]);
|
||||
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
||||
if (family) return _normValue(family[1]);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
// design-system-color compares by color value, not by spelling: the
|
||||
// browser reports computed rgb(...) strings while waivers are usually
|
||||
// written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in
|
||||
// cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms;
|
||||
// hsl stays CLI-only.
|
||||
const _colorKey = (value) => {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
|
||||
if (hex) {
|
||||
const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1];
|
||||
const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16));
|
||||
return `${r},${g},${b},${a}`;
|
||||
}
|
||||
const rgb = text.match(/^rgba?\((.*)\)$/);
|
||||
if (!rgb) return '';
|
||||
const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / ');
|
||||
let parts;
|
||||
if (body.includes(',')) {
|
||||
parts = body.split(',').map(p => p.trim()).filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.includes('/')) {
|
||||
parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)];
|
||||
}
|
||||
} else {
|
||||
parts = body.split(/\s+/).filter(p => p && p !== '/');
|
||||
}
|
||||
if (parts.length < 3 || parts.length > 4) return '';
|
||||
const channel = (raw, isAlpha) => {
|
||||
const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!m) return null;
|
||||
let v = parseFloat(m[1]);
|
||||
if (m[2]) v = isAlpha ? v / 100 : v * 2.55;
|
||||
const max = isAlpha ? 1 : 255;
|
||||
if (!Number.isFinite(v) || v < 0 || v > max) return null;
|
||||
return isAlpha ? v : Math.round(v);
|
||||
};
|
||||
const r = channel(parts[0], false);
|
||||
const g = channel(parts[1], false);
|
||||
const b = channel(parts[2], false);
|
||||
const a = parts[3] === undefined ? 1 : channel(parts[3], true);
|
||||
if ([r, g, b, a].some(v => v === null)) return '';
|
||||
return `${r},${g},${b},${Math.round(a * 255)}`;
|
||||
};
|
||||
const _valueIgnored = (f) => {
|
||||
const value = _findingValue(f);
|
||||
if (!value) return false;
|
||||
const rule = f.type || f.id;
|
||||
return _disabledValues.some(e => e.rule === rule && (e.value === value
|
||||
|| (rule === 'design-system-color'
|
||||
&& _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(value))));
|
||||
};
|
||||
for (const [el, list] of [...groupMap.entries()]) {
|
||||
const kept = list.filter(f => !_valueIgnored(f));
|
||||
if (kept.length > 0) groupMap.set(el, kept);
|
||||
else groupMap.delete(el);
|
||||
}
|
||||
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
|
||||
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groupMap,
|
||||
allFindings: browserFindingsFromMap(groupMap),
|
||||
@@ -8854,12 +8551,6 @@ if (IS_BROWSER) {
|
||||
|
||||
async function collectBrowserFindingsAsync(options = {}, runtime = {}) {
|
||||
const collected = collectBrowserFindings();
|
||||
// The visual pass walks the DOM on its own; on a skipScan page it would
|
||||
// repopulate the emptied scan, so it is skipped with everything else.
|
||||
if (skipScanActive()) {
|
||||
lastVisualContrastAnalyses = [];
|
||||
return { ...collected, allFindings: [], visualContrastAnalyses: [] };
|
||||
}
|
||||
await addVisualContrastFindings(collected.groupMap, options, runtime);
|
||||
return {
|
||||
...collected,
|
||||
@@ -8913,7 +8604,7 @@ if (IS_BROWSER) {
|
||||
const generation = scanGeneration;
|
||||
const collected = collectBrowserFindings();
|
||||
const allFindings = renderBrowserFindings(collected, options);
|
||||
if (!skipScanActive() && shouldRunVisualContrast(options)) {
|
||||
if (shouldRunVisualContrast(options)) {
|
||||
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
|
||||
.then(() => {
|
||||
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
|
||||
|
||||
@@ -162,68 +162,7 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
|
||||
// Puppeteer detection (for URLs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function decodeUrlComponent(value) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function splitScanUrl(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return { href: url, credentials: null };
|
||||
}
|
||||
if (!parsed.username && !parsed.password) {
|
||||
return { href: url, credentials: null };
|
||||
}
|
||||
const credentials =
|
||||
parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
? {
|
||||
username: decodeUrlComponent(parsed.username),
|
||||
password: decodeUrlComponent(parsed.password),
|
||||
}
|
||||
: null;
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
return { href: parsed.href, credentials };
|
||||
}
|
||||
|
||||
function basicAuthHeader(credentials) {
|
||||
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
// page.authenticate is page-wide: a cross-origin redirect that then 401s
|
||||
// would receive these credentials. Attach Authorization only to the scan origin.
|
||||
async function applyOriginScopedAuth(page, href, credentials) {
|
||||
if (!credentials) return;
|
||||
let origin = '';
|
||||
try {
|
||||
origin = new URL(href).origin;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!origin) return;
|
||||
const header = basicAuthHeader(credentials);
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
let headers;
|
||||
try {
|
||||
if (new URL(request.url()).origin === origin) {
|
||||
headers = { ...request.headers(), authorization: header };
|
||||
}
|
||||
} catch {
|
||||
// invalid request URL: continue without auth
|
||||
}
|
||||
void request.continue(headers ? { headers } : undefined).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
async function detectUrl(rawUrl, options = {}) {
|
||||
const { href: url, credentials } = splitScanUrl(rawUrl);
|
||||
async function detectUrl(url, options = {}) {
|
||||
const profile = options?.profile;
|
||||
const waitUntil = options?.waitUntil || 'networkidle0';
|
||||
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
|
||||
@@ -299,7 +238,6 @@ async function detectUrl(rawUrl, options = {}) {
|
||||
ruleId: 'set-viewport',
|
||||
target: url,
|
||||
}, () => page.setViewport(viewport));
|
||||
await applyOriginScopedAuth(page, url, credentials);
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
@@ -431,4 +369,4 @@ async function createBrowserDetector(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
|
||||
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { OVERUSED_FONTS, primaryFontFace } from '../../shared/constants.mjs';
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import {
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
@@ -51,7 +51,9 @@ 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 primary = primaryFontFace(window.getComputedStyle(el).fontFamily);
|
||||
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));
|
||||
if (!primary) continue;
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
|
||||
@@ -121,24 +121,6 @@ const ANTIPATTERNS = [
|
||||
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
|
||||
skillSection: 'Imagery',
|
||||
},
|
||||
{
|
||||
id: 'organic-clip-path',
|
||||
category: 'quality',
|
||||
name: 'Organic contour drawn as clip-path',
|
||||
description:
|
||||
'A clip-path polygon with many arbitrary vertices, or a curved clip-path path(), is CSS approximating a torn edge, blob, or silhouette. It reads as the cheap version of the effect and is usually a produced or photographic material replaced with code. Derive an alpha matte from the real image, or ship the shape as a cut-out raster; keep clip-path for geometry (cut corners, diagonals, hexagons).',
|
||||
skillSection: 'Imagery',
|
||||
skillGuideline: 'geometric masks standing in for organic contours',
|
||||
},
|
||||
{
|
||||
id: 'buried-raster',
|
||||
category: 'quality',
|
||||
name: 'Raster buried under a wash or opacity',
|
||||
description:
|
||||
'A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.',
|
||||
skillSection: 'Imagery',
|
||||
skillGuideline: 'a produced material must survive to the screen',
|
||||
},
|
||||
{
|
||||
id: 'dark-glow',
|
||||
category: 'slop',
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
isBrandFontOnOwnDomain,
|
||||
primaryFontFace,
|
||||
} from '../shared/constants.mjs';
|
||||
import {
|
||||
CSS_NAMED_COLORS,
|
||||
@@ -332,7 +331,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 = primaryFontFace(fontFamily, GENERIC_FONTS);
|
||||
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
|
||||
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 };
|
||||
@@ -683,13 +682,8 @@ function enclosingCssSelector(cssText, index) {
|
||||
if (!cssText || !Number.isFinite(index)) return null;
|
||||
const open = cssText.lastIndexOf('{', index);
|
||||
if (open === -1) return null;
|
||||
// A match inside an inline style fragment (`style="…"` appended to the
|
||||
// corpus by buildHtmlPatternCorpora) has no enclosing rule; the previous
|
||||
// `{` belongs to some other selector.
|
||||
const closeBeforeIndex = cssText.lastIndexOf('}', index);
|
||||
if (closeBeforeIndex > open) return null;
|
||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
||||
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
|
||||
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
|
||||
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
|
||||
// Keyframe steps: percentage steps fail the digit test above, but `from`
|
||||
// and `to` would read as (never-matching) type selectors and get a valid
|
||||
@@ -1455,95 +1449,6 @@ function scanHtmlForShapeAssembledIllustration(html) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
|
||||
// --- Organic clip-path polygons ----------------------------------------------
|
||||
// A `clip-path: polygon(...)` with many vertices, or `clip-path: path(...)`
|
||||
// with curves, is CSS approximating an organic contour: a torn edge, a blob,
|
||||
// a silhouette. The approximation reads as the cheap version of the effect
|
||||
// (the craft floor's geometric-occlusion-mask ban), and it is the signature
|
||||
// of a comp's produced material being replaced with code. Geometric clips
|
||||
// (cut corners, diagonals, hexagons, arrows: few vertices, or vertices on
|
||||
// the 0/50/100 grid) pass; circle()/inset()/ellipse() pass; a mask-image
|
||||
// from an alpha matte passes.
|
||||
const ORGANIC_POLYGON_MIN_VERTICES = 10;
|
||||
function scanCssTextForOrganicClipPath(styleText) {
|
||||
const findings = [];
|
||||
const re = /clip-path\s*:\s*(polygon|path)\s*\(([^)]*(?:\)[^;}]*)?)/gi;
|
||||
let m;
|
||||
while ((m = re.exec(styleText)) !== null) {
|
||||
const kind = m[1].toLowerCase();
|
||||
const body = m[2];
|
||||
if (kind === 'path') {
|
||||
// curves (C, S, Q, T, A, absolute or relative) drawing a contour, not a
|
||||
// rectilinear M/L/Z outline; letters in path data are only commands
|
||||
const curves = (body.match(/[CSQTA]/gi) || []).length;
|
||||
if (curves < 3) continue;
|
||||
findings.push({ id: 'organic-clip-path', snippet: `clip-path: path() with ${curves} curve segments`, selector: enclosingCssSelector(styleText, m.index) || undefined });
|
||||
continue;
|
||||
}
|
||||
const points = body.split(',').map((p) => p.trim()).filter(Boolean);
|
||||
if (points.length < ORGANIC_POLYGON_MIN_VERTICES) continue;
|
||||
// Vertices sitting on a coarse grid (multiples of 25%) are geometric; a
|
||||
// contour has arbitrary values.
|
||||
let offGrid = 0;
|
||||
for (const p of points) {
|
||||
const nums = p.match(/-?[\d.]+/g) || [];
|
||||
for (const n of nums) { const v = parseFloat(n); if (Math.abs(v - Math.round(v / 25) * 25) > 0.5) offGrid++; }
|
||||
}
|
||||
if (offGrid < points.length) continue;
|
||||
findings.push({ id: 'organic-clip-path', snippet: `clip-path: polygon() with ${points.length} vertices approximating an organic contour`, selector: enclosingCssSelector(styleText, m.index) || undefined });
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// --- Buried raster ------------------------------------------------------------
|
||||
// A raster (background-image url or <img>) that never reaches the screen:
|
||||
// under a near-opaque gradient wash in the same background stack, or on an
|
||||
// element at near-zero opacity. It is how a produced texture "ships" while
|
||||
// the page shows flat color, and the finish reviewer cannot see it either.
|
||||
// A tint under 0.9 alpha passes (hero darkening); a blend mode passes
|
||||
// (multiply/overlay keep the material visible); opacity >= 0.15 passes.
|
||||
function scanCssTextForBuriedRaster(styleText) {
|
||||
const findings = [];
|
||||
// background stacks: split declarations, look for url() + a gradient whose
|
||||
// stops all carry alpha >= 0.9 (or opaque hex/named colors)
|
||||
const declRe = /background(?:-image)?\s*:\s*([^;}]+)/gi;
|
||||
let m;
|
||||
while ((m = declRe.exec(styleText)) !== null) {
|
||||
const value = m[1];
|
||||
if (!/url\(/i.test(value) || !/gradient\(/i.test(value)) continue;
|
||||
// a blend mode declared in the same rule keeps the raster visible
|
||||
const ruleStart = styleText.lastIndexOf('{', m.index);
|
||||
const ruleEnd = styleText.indexOf('}', m.index);
|
||||
const rule = styleText.slice(ruleStart < 0 ? 0 : ruleStart, ruleEnd < 0 ? styleText.length : ruleEnd);
|
||||
if (/background-blend-mode\s*:\s*(?!normal)/i.test(rule) || /mix-blend-mode\s*:\s*(?!normal)/i.test(rule)) continue;
|
||||
// Layers are painted first-on-top: only a wash listed BEFORE the url()
|
||||
// covers it. An image on top of a gradient is not buried.
|
||||
const firstUrl = value.search(/url\(/i);
|
||||
const gradients = [...value.matchAll(/(?:linear|radial|conic)-gradient\([^()]*(?:\([^()]*\)[^()]*)*\)/gi)].filter((gm) => gm.index < firstUrl).map((gm) => gm[0]);
|
||||
let opaqueWash = false;
|
||||
// an alpha token normalized to 0..1: '0.8' -> 0.8, '80%' -> 0.8
|
||||
const alphaOf = (a) => { if (a == null) return 1; const v = parseFloat(a); return String(a).trim().endsWith('%') ? v / 100 : v; };
|
||||
for (const g of gradients) {
|
||||
const alphas = [...g.matchAll(/rgba?\(\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*(?:[,/]\s*([\d.]+%?))?\s*\)|hsla?\([^)]*?(?:[,/]\s*([\d.]+%?))?\s*\)/gi)].map((a) => alphaOf(a[1] ?? a[2]));
|
||||
const stripped = g.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)/gi, '');
|
||||
// hex stops: 4- and 8-digit forms carry their own alpha
|
||||
for (const h of stripped.matchAll(/#([0-9a-f]{3,8})\b/gi)) {
|
||||
const hex = h[1];
|
||||
if (hex.length === 4) alphas.push(parseInt(hex[3] + hex[3], 16) / 255);
|
||||
else if (hex.length === 8) alphas.push(parseInt(hex.slice(6), 16) / 255);
|
||||
else alphas.push(1);
|
||||
}
|
||||
const named = /\b(?:white|black|ivory|beige|linen|snow|cream)\b/i.test(stripped);
|
||||
if (named) alphas.push(1);
|
||||
if (alphas.length && alphas.every((a) => !Number.isFinite(a) || a >= 0.9)) { opaqueWash = true; break; }
|
||||
}
|
||||
if (!opaqueWash) continue;
|
||||
findings.push({ id: 'buried-raster', snippet: `raster under a near-opaque gradient wash: ${value.trim().slice(0, 90)}`, selector: enclosingCssSelector(styleText, m.index) || undefined });
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Scoped scan corpora for the page-level pattern checks. CSS-property
|
||||
// regexes run over the whole source string fire on documentation ABOUT
|
||||
// css — `<code>background-clip: text</code>` prose, <pre> samples, HTML
|
||||
@@ -1716,10 +1621,6 @@ function checkHtmlPatterns(html, corpora) {
|
||||
// Shape-assembled illustrations (large pictorial SVGs built from primitives)
|
||||
findings.push(...scanHtmlForShapeAssembledIllustration(html));
|
||||
|
||||
// Organic clip-path contours and rasters buried under washes or opacity
|
||||
findings.push(...scanCssTextForOrganicClipPath(styleText));
|
||||
findings.push(...scanCssTextForBuriedRaster(styleText));
|
||||
|
||||
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
|
||||
findings.push(...scanCssTextForMarquee(styleText, html));
|
||||
|
||||
@@ -3190,30 +3091,14 @@ function isNonRenderedText(el, tag, style) {
|
||||
function checkQuality(opts) {
|
||||
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
|
||||
const findings = [];
|
||||
// A raster (<img>, or an element with a background url) at near-zero
|
||||
// opacity never reaches the screen: the produced material ships as a
|
||||
// compliance token. The CSS-text scan catches the stylesheet form; this
|
||||
// catches computed opacity on the element itself (both engines).
|
||||
// Skip browser extension injected elements BEFORE any finding is pushed
|
||||
// (a low-opacity raster those hosts inject used to be recorded and then
|
||||
// returned by this very skip). Read the id via getAttribute whenever
|
||||
// `el.id` is not a string: on a <form> (and other [LegacyOverrideBuiltIns]
|
||||
// hosts) a named control like <input name="id"> shadows the builtin `id`
|
||||
// getter and returns the control element, whose `.startsWith` is undefined
|
||||
// and throws (issue #407 — every Shopify product form ships an
|
||||
// <input name="id">).
|
||||
// Skip browser extension injected elements. Read the id via getAttribute
|
||||
// whenever `el.id` is not a string: on a <form> (and other
|
||||
// [LegacyOverrideBuiltIns] hosts) a named control like <input name="id">
|
||||
// shadows the builtin `id` getter and returns the control element, whose
|
||||
// `.startsWith` is undefined and throws (issue #407 — every Shopify product
|
||||
// form ships an <input name="id">).
|
||||
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || '');
|
||||
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
|
||||
{
|
||||
const op = parseFloat(style.opacity);
|
||||
if (Number.isFinite(op) && op < 0.15 && op >= 0) {
|
||||
const bg = String(style.backgroundImage || '');
|
||||
if (tag === 'img' || /url\(/i.test(bg)) {
|
||||
const label = tag === 'img' ? (el.getAttribute && el.getAttribute('alt')) || '' : (el.textContent || '').trim().slice(0, 40);
|
||||
findings.push({ id: 'buried-raster', snippet: `${tag === 'img' ? '<img>' : 'raster background'} at opacity ${op}${label ? ` "${label}"` : ''}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Line length too long --- (browser-only: needs rect.width)
|
||||
if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
|
||||
@@ -3931,7 +3816,8 @@ function checkTypography() {
|
||||
const style = getComputedStyle(el);
|
||||
const ff = style.fontFamily;
|
||||
if (!ff) continue;
|
||||
const primary = primaryFontFace(ff);
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
if (!primary) continue;
|
||||
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
||||
totalTextElements++;
|
||||
@@ -4176,7 +4062,8 @@ function checkPageTypography(doc, win) {
|
||||
if (rule.type !== 1) continue;
|
||||
const ff = rule.style?.fontFamily;
|
||||
if (!ff) continue;
|
||||
const primary = primaryFontFace(ff);
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
@@ -4195,10 +4082,11 @@ function checkPageTypography(doc, win) {
|
||||
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
let fm;
|
||||
while ((fm = ffRe.exec(html)) !== null) {
|
||||
const primary = primaryFontFace(fm[1]);
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5274,36 +5162,6 @@ function checkTextOcclusionDOM() {
|
||||
// reads as an opaque box.
|
||||
const effectiveOpacity = effectiveOpacityDOM;
|
||||
|
||||
// The part of an element that is actually painted, after every scrolling or
|
||||
// clipping ancestor has had its say.
|
||||
//
|
||||
// getBoundingClientRect reports where a box would be if nothing cut it off,
|
||||
// so a paragraph half scrolled out of a panel still reports its full height,
|
||||
// and the half that is clipped away lands wherever the page continues below
|
||||
// the panel. The elementFromPoint probe then samples coordinates the text is
|
||||
// not painted at, finds whatever genuinely is painted there, and reports the
|
||||
// text as buried under it. Any sticky footer or toolbar beneath a scroll
|
||||
// region produces this, and it is the shape most likely to be waved off as
|
||||
// noise, which costs the rule its credibility on the findings that are real.
|
||||
//
|
||||
// Border box rather than padding box on purpose: it errs toward probing, and
|
||||
// giving up a scrollbar gutter's width would drop true findings at the right
|
||||
// edge of a scroller.
|
||||
const paintedRect = (el, rect) => {
|
||||
let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
|
||||
for (let cur = el.parentElement; cur && cur !== document.documentElement; cur = cur.parentElement) {
|
||||
let cs; try { cs = getComputedStyle(cur); } catch { continue; }
|
||||
const clipsX = String(cs.overflowX || 'visible') !== 'visible';
|
||||
const clipsY = String(cs.overflowY || 'visible') !== 'visible';
|
||||
if (!clipsX && !clipsY) continue;
|
||||
let b; try { b = cur.getBoundingClientRect(); } catch { continue; }
|
||||
if (clipsX) { left = Math.max(left, b.left); right = Math.min(right, b.right); }
|
||||
if (clipsY) { top = Math.max(top, b.top); bottom = Math.min(bottom, b.bottom); }
|
||||
if (right - left < 1 || bottom - top < 1) return null;
|
||||
}
|
||||
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
||||
};
|
||||
|
||||
// Collect renderable text owners in / near the first viewport for the
|
||||
// elementFromPoint probe. SVG <text> counts too.
|
||||
const textEls = [];
|
||||
@@ -5316,13 +5174,8 @@ function checkTextOcclusionDOM() {
|
||||
if (text.length < 2) continue;
|
||||
if (!isPaintedForOcclusion(el)) continue;
|
||||
if (effectiveOpacity(el) <= 0.02) continue;
|
||||
let full; try { full = el.getBoundingClientRect(); } catch { continue; }
|
||||
if (full.width < 6 || full.height < 6) continue;
|
||||
// Probe only where the text is on screen. A run clipped down to a sliver is
|
||||
// dropped rather than sampled: a few pixels of visible text cannot support
|
||||
// a coverage fraction worth reporting either way.
|
||||
const rect = paintedRect(el, full);
|
||||
if (!rect || rect.width < 6 || rect.height < 6) continue;
|
||||
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
|
||||
if (rect.width < 6 || rect.height < 6) continue;
|
||||
// Viewport-bound probe: keep text whose box overlaps the live viewport.
|
||||
if (rect.bottom <= 0 || rect.top >= vh) continue;
|
||||
textEls.push({ el, rect, text, inSvg });
|
||||
@@ -5591,8 +5444,6 @@ export {
|
||||
cssLengthToPx,
|
||||
scanCssTextForPulsingDot,
|
||||
scanHtmlForShapeAssembledIllustration,
|
||||
scanCssTextForOrganicClipPath,
|
||||
scanCssTextForBuriedRaster,
|
||||
buildHtmlPatternCorpora,
|
||||
checkHtmlPatterns,
|
||||
readOwnBackgroundColor,
|
||||
|
||||
@@ -56,27 +56,13 @@ function isBrandFontOnOwnDomain(font) {
|
||||
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||
}
|
||||
|
||||
// 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',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
...CSS_GENERIC_FONTS,
|
||||
'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',
|
||||
]);
|
||||
|
||||
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);
|
||||
@@ -118,7 +104,6 @@ export {
|
||||
BRAND_FONT_DOMAINS,
|
||||
isBrandFontOnOwnDomain,
|
||||
GENERIC_FONTS,
|
||||
primaryFontFace,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
|
||||
@@ -21,24 +21,22 @@ 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 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;
|
||||
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);
|
||||
if (prompt == null && fs.existsSync(`${imagePath}.json`)) {
|
||||
try { prompt = JSON.parse(fs.readFileSync(`${imagePath}.json`, 'utf8')).prompt ?? null; } catch { /* stays null */ }
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
if (args.includes('--scan')) {
|
||||
if (scanMode) {
|
||||
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;
|
||||
@@ -61,7 +59,7 @@ if (args.includes('--scan')) {
|
||||
}
|
||||
let missing = 0;
|
||||
for (const raster of rasters) {
|
||||
if (readPrompt(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
|
||||
if (promptOf(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);
|
||||
@@ -70,7 +68,8 @@ if (args.includes('--scan')) {
|
||||
if (!file || !fs.existsSync(file)) { console.error('embed-prompt: image file required'); process.exit(1); }
|
||||
|
||||
const buf = fs.readFileSync(file);
|
||||
const type = imageType(buf);
|
||||
const isPng = buf.length > 8 && buf.readUInt32BE(0) === 0x89504e47;
|
||||
const isJpeg = buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8;
|
||||
|
||||
const crcTable = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
@@ -88,26 +87,22 @@ function pngChunk(type, data) {
|
||||
return out;
|
||||
}
|
||||
|
||||
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');
|
||||
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');
|
||||
}
|
||||
}
|
||||
chunks.push({ offset, type, promptChunk, bytes: buffer.subarray(offset, offset + 12 + length) });
|
||||
offset += 12 + length;
|
||||
off += 12 + len;
|
||||
}
|
||||
return { chunks, prompt };
|
||||
return null;
|
||||
}
|
||||
|
||||
function readJpegCom(b) {
|
||||
@@ -126,34 +121,48 @@ function readJpegCom(b) {
|
||||
}
|
||||
|
||||
const sidecar = `${file}.json`;
|
||||
if (args.includes('--read')) {
|
||||
const prompt = readPrompt(file, buf);
|
||||
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 (prompt == null) { console.error('embed-prompt: no embedded prompt found'); process.exit(2); }
|
||||
console.log(prompt);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const promptFile = argOf('--prompt-file');
|
||||
const prompt = argOf('--prompt') ?? (promptFile ? fs.readFileSync(promptFile, 'utf8') : null);
|
||||
const prompt = argOf('--prompt') ?? (argOf('--prompt-file') ? fs.readFileSync(argOf('--prompt-file'), 'utf8') : null);
|
||||
if (!prompt) { console.error('embed-prompt: --prompt or --prompt-file required'); process.exit(1); }
|
||||
|
||||
if (type === 'png') {
|
||||
if (isPng) {
|
||||
// Insert (or replace) our tEXt chunk immediately before IEND.
|
||||
const { chunks, prompt: existingPrompt } = parsePng(buf);
|
||||
const iend = chunks.find((chunk) => chunk.type === 'IEND')?.offset ?? -1;
|
||||
const iend = buf.indexOf(Buffer.from('IEND', 'ascii')) - 4;
|
||||
if (iend < 8) { console.error('embed-prompt: malformed PNG'); process.exit(1); }
|
||||
// Drop any existing chunk with our keyword to keep embedding idempotent.
|
||||
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]));
|
||||
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)]));
|
||||
}
|
||||
console.log(`EMBEDDED: ${file} (png tEXt, ${prompt.length} chars)`);
|
||||
} else if (type === 'jpeg') {
|
||||
} else if (isJpeg) {
|
||||
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);
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* font-match: measure the lettering in a comp's text region and rank candidate
|
||||
* faces against it, so the face is chosen by metrics instead of by name.
|
||||
*
|
||||
* node font-match.mjs --measure <region-id> [--spec .impeccable/build/spec.json]
|
||||
* Fingerprints the comp crop of a text region (lib/font-fingerprint.mjs):
|
||||
* cap height (px), glyph width per cap height (width class), stroke
|
||||
* density and stem width (weight class), tracking, plus the size-invariant
|
||||
* shape vector the ranking uses. Prints the summary and stores it on the
|
||||
* region in the spec (`type` block), so build code can set font-size from
|
||||
* capHeightPx and the hero gate can name a width/weight miss.
|
||||
*
|
||||
* node font-match.mjs --rank <region-id> [--candidates "Barlow Condensed:700,Oswald:600"] [--text "The manuals stop."] [--category sans,display]
|
||||
* Candidates come from a fingerprint index of the Google Fonts catalog
|
||||
* (data/font-index.json, ~3,000 faces at two cap heights; the crop is
|
||||
* routed to the 14px or 48px index by its cap height): the 25 nearest
|
||||
* faces by fingerprint distance, plus the names you pass. Each candidate
|
||||
* is then rendered with the region's text at the comp's cap height in a
|
||||
* headless browser (Google Fonts CSS), fingerprinted the same way, and
|
||||
* ranked by the same distance on the rendered text. Prints CATALOG (the
|
||||
* index's top five), the ranking with per-face width and weight deltas,
|
||||
* a proof sheet, and the CSS to use (family, weight, and the font-size
|
||||
* that reproduces the comp's cap height). Needs a browser: playwright or
|
||||
* puppeteer resolvable from the project or the impeccable CLI; without
|
||||
* one, the CATALOG line is the ranking. Without the index the built-in
|
||||
* per-width-class shortlist stands in.
|
||||
*
|
||||
* Why: models pick faces from memory and never measure. Three of the six
|
||||
* misses a human called on a first-round build were the same miss: the
|
||||
* headline face wider and lighter than the comp's, the parts list smaller,
|
||||
* the footer heavier. All three are ratios a script can read off pixels.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { decodePng, encodePng, loadRaster } from './lib/png.mjs';
|
||||
import { crop } from './lib/raster.mjs';
|
||||
import { fingerprint, distance } from './lib/font-fingerprint.mjs';
|
||||
import { loadFontIndex, candidatesFromIndex, MIN_RANK_CAP_PX } from './lib/font-index.mjs';
|
||||
import { loadSpec, SPEC_PATH } from './comp-spec.mjs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function arg(name, fallback = null) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
if (i === -1) return fallback;
|
||||
const v = process.argv[i + 1];
|
||||
return v && !v.startsWith('--') ? v : fallback;
|
||||
}
|
||||
|
||||
// ---- fingerprint ----------------------------------------------------------
|
||||
// fingerprint(img) and distance(a, b) live in lib/font-fingerprint.mjs: size-
|
||||
// invariant shape features per text line (advance, x-ratio, stem width,
|
||||
// contrast, serif, density, ink profiles) and a noise-normalized weighted L1
|
||||
// fitted on held-out Google Fonts probes. The class helpers below turn two of
|
||||
// those features into the words the MEASURE line prints.
|
||||
|
||||
/**
|
||||
* The feature that reads as width: advX (x-height glyph width / R) on a
|
||||
* mixed-case crop, advTall (cap glyph width / R) when the crop is all caps.
|
||||
* Thresholds sit on the catalog index (advX 0.20 quantile 0.58, median 0.64,
|
||||
* 0.80 quantile 0.71) anchored by named faces: League Gothic 0.36, Oswald 0.42,
|
||||
* Anton 0.48, Barlow Condensed 0.54, Roboto Condensed 0.58, Roboto 0.62,
|
||||
* Inter 0.65, Space Grotesk 0.71, Montserrat Bold 0.76, Archivo Black 0.87.
|
||||
*/
|
||||
export function widthMeasure(fp) {
|
||||
if (!fp) return null;
|
||||
if (fp.advX != null) return { key: 'advX', value: fp.advX };
|
||||
if (fp.advTall != null) return { key: 'advTall', value: fp.advTall };
|
||||
if (fp.advance != null) return { key: 'advance', value: fp.advance };
|
||||
return null;
|
||||
}
|
||||
export function widthClass(fp) {
|
||||
const m = typeof fp === 'number' ? { key: 'advX', value: fp } : widthMeasure(fp);
|
||||
if (!m) return 'normal';
|
||||
// cap widths run ~10% wider than x-height widths against the same R
|
||||
const t = m.key === 'advTall' ? [0.45, 0.61, 0.78] : [0.42, 0.585, 0.72];
|
||||
if (m.value < t[0]) return 'compressed';
|
||||
if (m.value < t[1]) return 'condensed';
|
||||
if (m.value < t[2]) return 'normal';
|
||||
return 'wide';
|
||||
}
|
||||
/**
|
||||
* The feature that reads as weight: densTall (ink / bbox area of cap-height
|
||||
* glyphs); stemW (stem width / R) when no cap glyph was separable. Catalog
|
||||
* anchors for densTall: Lato 300 0.27, Roboto 300 0.32, Playfair 400 0.37,
|
||||
* Inter 400 0.44, Roboto 700 0.59, Work Sans 700 0.64, Bebas Neue 0.68,
|
||||
* Oswald 700 0.72, League Gothic 0.76, Anton 0.79. For stemW: Roboto 300 0.10,
|
||||
* Roboto 400 0.14, Inter 700 0.22, Archivo Black 0.30.
|
||||
*/
|
||||
export function weightMeasure(fp) {
|
||||
if (!fp) return null;
|
||||
if (fp.densTall != null) return { key: 'densTall', value: fp.densTall };
|
||||
if (fp.densX != null) return { key: 'densX', value: fp.densX };
|
||||
if (fp.stemW != null) return { key: 'stemW', value: fp.stemW };
|
||||
if (fp.weight != null) return { key: 'weight', value: fp.weight };
|
||||
return null;
|
||||
}
|
||||
export function weightClass(fp) {
|
||||
const m = typeof fp === 'number' ? { key: 'densTall', value: fp } : weightMeasure(fp);
|
||||
if (!m) return 'regular';
|
||||
const t = m.key === 'stemW' ? [0.105, 0.165, 0.195, 0.24] : [0.34, 0.48, 0.56, 0.66];
|
||||
if (m.value < t[0]) return 'light';
|
||||
if (m.value < t[1]) return 'regular';
|
||||
if (m.value < t[2]) return 'medium';
|
||||
if (m.value < t[3]) return 'bold';
|
||||
return 'black';
|
||||
}
|
||||
|
||||
/**
|
||||
* A starter shortlist per width class, Google Fonts only, chosen to span
|
||||
* weight and character inside the class. Used only when the catalog index
|
||||
* (data/font-index.json) is missing; with the index, candidates come from
|
||||
* the comp's fingerprint and the model's own names.
|
||||
*/
|
||||
export const SHORTLIST = {
|
||||
compressed: ['League Gothic:400', 'Bebas Neue:400', 'Anton:400', 'Six Caps:400', 'Big Shoulders Display:900', 'Antonio:700', 'Saira Extra Condensed:800', 'Oswald:700'],
|
||||
condensed: ['League Gothic:400', 'Fjalla One:400', 'Anton:400', 'Bebas Neue:400', 'Oswald:600', 'Barlow Condensed:700', 'Roboto Condensed:800', 'Archivo Narrow:700', 'Pathway Gothic One:400', 'Big Shoulders Display:800', 'Teko:600', 'Sofia Sans Condensed:800'],
|
||||
normal: ['Inter:700', 'Work Sans:700', 'IBM Plex Sans:700', 'Archivo:800', 'Public Sans:700', 'Source Sans 3:700', 'Roboto:900', 'Barlow:800', 'Manrope:800', 'Rubik:800'],
|
||||
wide: ['Archivo Black:400', 'Syne:800', 'Space Grotesk:700', 'Unbounded:700', 'Bricolage Grotesque:800', 'Sora:800', 'Outfit:800', 'Lexend:800'],
|
||||
};
|
||||
|
||||
/** Weight-shifted variants of a candidate list, one step lighter and heavier; the ranking decides. */
|
||||
export function withWeightVariants(list) {
|
||||
const out = [];
|
||||
for (const c of list) {
|
||||
out.push(c);
|
||||
const m = /^(.*?):(\d{3})$/.exec(c);
|
||||
if (!m) continue;
|
||||
const w = parseInt(m[2], 10);
|
||||
for (const d of [-200, 200]) { const nw = w + d; if (nw >= 100 && nw <= 900) out.push(`${m[1]}:${nw}`); }
|
||||
}
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate faces for a comp fingerprint: the nearest index faces (top n by
|
||||
* fingerprint distance, routed to the 14px or 48px index by the crop's cap
|
||||
* height, optionally filtered by category), the caller's own names first,
|
||||
* and the built-in shortlist only when there is no index. Returns
|
||||
* { candidates: [{ family, weight }], catalog: [index hits], source }.
|
||||
*/
|
||||
export function selectCandidates(fp, { own = [], index = null, n = 25, category = null } = {}) {
|
||||
const catalog = index ? candidatesFromIndex(fp, index, { n, category }) : [];
|
||||
const list = [...own, ...catalog.map((c) => ({ family: c.family, weight: c.weight }))];
|
||||
let source = 'index';
|
||||
if (!index) {
|
||||
source = 'shortlist';
|
||||
for (const s of withWeightVariants(SHORTLIST[widthClass(fp)] || SHORTLIST.normal)) list.push(parseCandidates(s)[0]);
|
||||
}
|
||||
const seen = new Set();
|
||||
const candidates = list.filter((c) => { const k = `${c.family}:${c.weight}`; if (seen.has(k)) return false; seen.add(k); return true; });
|
||||
return { candidates, catalog, source };
|
||||
}
|
||||
|
||||
/**
|
||||
* A choice font-match wrote carries a stamp over its own fields, so the spec
|
||||
* gate can tell a measured choice from a hand-typed one. Sessions with no
|
||||
* browser wrote `"chosen": { "family": "Arial Narrow", "source": "system-fallback" }`
|
||||
* straight into spec.json to get past the gate; that is the guess the gate
|
||||
* exists to refuse. Not secret, just not something a model reaches for.
|
||||
*/
|
||||
export function stampChoice(regionId, chosen) {
|
||||
const h = createHash('sha1').update(`font-match:${regionId}:${chosen.family}:${chosen.weight}:${chosen.fontSizePx}:${chosen.source}`).digest('hex').slice(0, 12);
|
||||
return { ...chosen, stamp: h };
|
||||
}
|
||||
export function choiceStamped(regionId, chosen) {
|
||||
if (!chosen || !chosen.stamp) return false;
|
||||
return stampChoice(regionId, { ...chosen, stamp: undefined }).stamp === chosen.stamp;
|
||||
}
|
||||
|
||||
// ---- browser --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Playwright and puppeteer write launch artifacts to os.tmpdir(). In a
|
||||
* sandbox whose /tmp is not writable (the ninth sweep: EPERM on
|
||||
* mkdtemp /tmp/playwright-artifacts-*), every rank silently fell back to the
|
||||
* catalog and three of four builds set headlines at twice the comp's cap.
|
||||
* Probe once and point TMPDIR at a workspace dir when the system one fails.
|
||||
*/
|
||||
function ensureWritableTmp() {
|
||||
const os = require('node:os');
|
||||
try { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'fm-')); fs.rmSync(d, { recursive: true, force: true }); return; } catch { /* not writable */ }
|
||||
const local = path.resolve('.impeccable', 'tmp');
|
||||
try { fs.mkdirSync(local, { recursive: true }); process.env.TMPDIR = local; process.env.TMP = local; process.env.TEMP = local; } catch { /* leave as is; launch will say why */ }
|
||||
}
|
||||
|
||||
async function loadBrowser() {
|
||||
ensureWritableTmp();
|
||||
// IMPECCABLE_NODE_MODULES: a node_modules dir holding playwright or
|
||||
// puppeteer, for harnesses that mount the skill somewhere its own resolution
|
||||
// roots cannot see (a sandbox root, a plugin cache). NODE_PATH works too.
|
||||
const extra = (process.env.IMPECCABLE_NODE_MODULES || '').split(path.delimiter).filter(Boolean);
|
||||
const tries = [
|
||||
...extra.map((dir) => () => require(require.resolve('playwright', { paths: [dir, path.dirname(dir)] }))),
|
||||
() => require('playwright'),
|
||||
() => require(require.resolve('playwright', { paths: [process.cwd()] })),
|
||||
() => require(require.resolve('playwright', { paths: [path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..')] })),
|
||||
];
|
||||
for (const t of tries) { try { const pw = t(); if (pw?.chromium) return { kind: 'playwright', mod: pw }; } catch { /* next */ } }
|
||||
const tries2 = [
|
||||
...extra.map((dir) => () => require(require.resolve('puppeteer', { paths: [dir, path.dirname(dir)] }))),
|
||||
() => require('puppeteer'),
|
||||
() => require(require.resolve('puppeteer', { paths: [process.cwd()] })),
|
||||
() => require(require.resolve('puppeteer', { paths: [path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..')] })),
|
||||
];
|
||||
for (const t of tries2) { try { const pp = t(); if (pp?.launch) return { kind: 'puppeteer', mod: pp }; } catch { /* next */ } }
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCandidates(s) {
|
||||
return String(s || '').split(',').map((x) => x.trim()).filter(Boolean).map((x) => {
|
||||
const m = /^(.*?)(?::(\d{3}))?$/.exec(x);
|
||||
return { family: m[1].trim(), weight: m[2] ? parseInt(m[2], 10) : 400 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Render `text` in each candidate at a font-size whose measured cap height ~= targetCapPx; return fingerprints. */
|
||||
export async function renderCandidates(candidates, text, targetCapPx, { transform = 'none' } = {}) {
|
||||
const b = await loadBrowser();
|
||||
if (!b) return null;
|
||||
// A resolvable module whose browser binary is absent (CI, a fresh install
|
||||
// without npx playwright install) throws at launch; that is the same
|
||||
// situation as no module, and the catalog fallback owns it.
|
||||
let browser;
|
||||
try { browser = b.kind === 'playwright' ? await b.mod.chromium.launch() : await b.mod.launch({ headless: true }); } catch { return null; }
|
||||
// One stylesheet per family+weight: a combined request 400s when any one
|
||||
// family lacks the requested axis (Anton has no wght range), and a static
|
||||
// family answers only for the weights it ships.
|
||||
const links = candidates.map((c) => `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(c.family).replace(/%20/g, '+')}:wght@${c.weight}&display=block">`).join('');
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8">${links}<style>body{margin:0;background:#fff}div.s{position:absolute;left:0;top:0;white-space:nowrap;color:#000;line-height:1;padding:8px;text-transform:${transform}}</style></head><body></body></html>`;
|
||||
const results = [];
|
||||
const size0 = Math.max(12, Math.round(targetCapPx * 1.4));
|
||||
if (b.kind === 'playwright') {
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 400 }, deviceScaleFactor: 1 });
|
||||
await page.setContent(html, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(800);
|
||||
for (const c of candidates) {
|
||||
// two passes: measure at size0, then rescale so the fingerprint's cap height matches the comp
|
||||
let size = size0, fp = null, ok = true;
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
await page.evaluate(({ family, weight, size, text }) => {
|
||||
document.body.innerHTML = `<div class="s" style="font-family:'${family}',sans-serif;font-weight:${weight};font-size:${size}px">${text}</div>`;
|
||||
}, { family: c.family, weight: c.weight, size, text });
|
||||
let loaded = false;
|
||||
// Loaded means a real face of this family covers the requested weight;
|
||||
// fonts.check() answers true for a synthetic bold of a lighter file.
|
||||
try {
|
||||
loaded = await page.evaluate(async (f) => {
|
||||
const faces = await document.fonts.load(`${f.weight} 32px '${f.family}'`);
|
||||
await document.fonts.ready;
|
||||
const covers = (face) => { const w = String(face.weight || '400').split(/\s+/).map(Number); const lo = w[0], hi = w[1] ?? w[0]; return f.weight >= lo - 50 && f.weight <= hi + 50; };
|
||||
return faces.some((face) => face.family.replace(/["']/g, '') === f.family && face.status === 'loaded' && covers(face));
|
||||
}, c);
|
||||
} catch { loaded = false; }
|
||||
await page.waitForTimeout(100);
|
||||
if (!loaded) ok = false;
|
||||
const box = await page.evaluate(() => { const r = document.querySelector('div.s').getBoundingClientRect(); return { w: Math.ceil(r.width) + 8, h: Math.ceil(r.height) + 8 }; });
|
||||
const buf = await page.screenshot({ clip: { x: 0, y: 0, width: Math.min(1600, box.w), height: Math.min(400, box.h) } });
|
||||
fp = fingerprint(decodePng(buf));
|
||||
if (!fp || pass === 1) break;
|
||||
size = Math.max(8, Math.round(size * (targetCapPx / fp.capHeightPx)));
|
||||
}
|
||||
results.push({ ...c, loaded: ok, fontSizePx: size, fp });
|
||||
}
|
||||
await browser.close();
|
||||
} else {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 400 });
|
||||
await page.setContent(html, { waitUntil: 'load' });
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
for (const c of candidates) {
|
||||
let size = size0, fp = null, ok = true;
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
await page.evaluate(({ family, weight, size, text }) => {
|
||||
document.body.innerHTML = `<div class="s" style="font-family:'${family}',sans-serif;font-weight:${weight};font-size:${size}px">${text}</div>`;
|
||||
}, { family: c.family, weight: c.weight, size, text });
|
||||
let loaded = false;
|
||||
// Loaded means a real face of this family covers the requested weight;
|
||||
// fonts.check() answers true for a synthetic bold of a lighter file.
|
||||
try {
|
||||
loaded = await page.evaluate(async (f) => {
|
||||
const faces = await document.fonts.load(`${f.weight} 32px '${f.family}'`);
|
||||
await document.fonts.ready;
|
||||
const covers = (face) => { const w = String(face.weight || '400').split(/\s+/).map(Number); const lo = w[0], hi = w[1] ?? w[0]; return f.weight >= lo - 50 && f.weight <= hi + 50; };
|
||||
return faces.some((face) => face.family.replace(/["']/g, '') === f.family && face.status === 'loaded' && covers(face));
|
||||
}, c);
|
||||
} catch { loaded = false; }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
if (!loaded) ok = false;
|
||||
const box = await page.evaluate(() => { const r = document.querySelector('div.s').getBoundingClientRect(); return { w: Math.ceil(r.width) + 8, h: Math.ceil(r.height) + 8 }; });
|
||||
const buf = await page.screenshot({ clip: { x: 0, y: 0, width: Math.min(1600, box.w), height: Math.min(400, box.h) } });
|
||||
fp = fingerprint(decodePng(buf));
|
||||
if (!fp || pass === 1) break;
|
||||
size = Math.max(8, Math.round(size * (targetCapPx / fp.capHeightPx)));
|
||||
}
|
||||
results.push({ ...c, loaded: ok, fontSizePx: size, fp });
|
||||
}
|
||||
await browser.close();
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Comp crop over the top candidates, rendered at the comp's cap height, as one PNG. */
|
||||
export async function renderProofSheet(compCrop, top, text, capPx, transform = 'none') {
|
||||
const b = await loadBrowser();
|
||||
if (!b || b.kind !== 'playwright') return null;
|
||||
const compB64 = Buffer.from(encodePng(compCrop)).toString('base64');
|
||||
const links = top.map((c) => `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(c.family).replace(/%20/g, '+')}:wght@${c.weight}&display=block">`).join('');
|
||||
const rowsHtml = top.map((c) => `<div class="row"><div class="lab">${c.family} ${c.weight} · ${c.fontSizePx}px</div><div class="s" style="font-family:'${c.family}';font-weight:${c.weight};font-size:${c.fontSizePx}px;text-transform:${transform}">${text}</div></div>`).join('');
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8">${links}<style>body{margin:0;background:#fff;padding:12px;font-family:system-ui}img{display:block;max-width:100%}.lab{font:12px system-ui;color:#666;margin:10px 0 2px}.s{white-space:nowrap;line-height:1.05;color:#111}</style></head><body><div class="lab">COMP</div><img src="data:image/png;base64,${compB64}">${rowsHtml}</body></html>`;
|
||||
let browser;
|
||||
try { browser = await b.mod.chromium.launch(); } catch { return null; }
|
||||
const page = await browser.newPage({ viewport: { width: Math.min(1600, Math.max(600, compCrop.width + 24)), height: 200 } });
|
||||
await page.setContent(html, { waitUntil: 'load' });
|
||||
try { await page.evaluate(async () => { await document.fonts.ready; }); } catch { /* ignore */ }
|
||||
await page.waitForTimeout(600);
|
||||
const buf = await page.screenshot({ fullPage: true });
|
||||
await browser.close();
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- CLI ------------------------------------------------------------------
|
||||
|
||||
function describe(fp) {
|
||||
const wm = widthMeasure(fp), wt = weightMeasure(fp);
|
||||
const wmS = wm ? ` (${wm.key} ${wm.value})` : '';
|
||||
const wtS = wt ? ` (${wt.key} ${wt.value})` : '';
|
||||
return `capHeight ${fp.capHeightPx}px, width ${widthClass(fp)}${wmS}, weight ${weightClass(fp)}${wtS}, tracking ${fp.gap}${fp.allCaps ? ', all caps' : ''}`;
|
||||
}
|
||||
|
||||
/** Fingerprint fields the spec keeps for a region: the class-bearing features plus the shape summary, not the whole vector. */
|
||||
function compactFp(fp) {
|
||||
if (!fp) return fp;
|
||||
const keep = ['lines', 'glyphs', 'capHeightPx', 'inkIsDark', 'allCaps', 'advance', 'advTall', 'advX', 'gap', 'xRatio', 'stemW', 'contrast', 'serif', 'densTall', 'densX', 'weight'];
|
||||
const out = {};
|
||||
for (const k of keep) if (fp[k] !== undefined) out[k] = fp[k];
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const specPath = arg('spec', SPEC_PATH);
|
||||
const spec = loadSpec(specPath);
|
||||
const measureId = arg('measure'), rankId = arg('rank');
|
||||
const id = measureId || rankId;
|
||||
if (!id) {
|
||||
console.error('usage: font-match.mjs --measure <text-region-id> | --rank <text-region-id> [--candidates "Family:700,Family2:400,..."] [--text "..."] [--transform uppercase] [--category sans,serif,display,handwriting,mono]');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!spec) { console.error(`font-match: no spec at ${specPath}; run comp-spec.mjs first`); process.exit(1); }
|
||||
const region = spec.regions.find((r) => r.id === id);
|
||||
if (!region) { console.error(`font-match: no region ${id}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); }
|
||||
const comp = loadRaster(spec.comp).image;
|
||||
const c = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h);
|
||||
const fp = fingerprint(c);
|
||||
if (!fp) {
|
||||
// Record the attempt so the spec gate does not ask again; a region with
|
||||
// no separable glyphs (a rule, a bar of solid ink, a very small label at
|
||||
// comp resolution) is measured as "no lettering" and the model sizes it
|
||||
// by its box.
|
||||
region.type = { ...(region.type || {}), comp: null, measuredAt: new Date().toISOString(), note: 'no separable lettering in the crop; size by the region box' };
|
||||
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
||||
console.log(`MEASURE ${id}: no separable lettering in the region crop at comp resolution; size this text by its box (${region.px.w}x${region.px.h}px) and inherit face and weight from the nearest measured region.`);
|
||||
process.exit(0);
|
||||
}
|
||||
region.type = { ...(region.type || {}), comp: compactFp(fp), widthClass: widthClass(fp), weightClass: weightClass(fp) };
|
||||
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
||||
console.log(`MEASURE ${id}: ${describe(fp)} over ${fp.lines} line${fp.lines === 1 ? '' : 's'}, ${fp.glyphs} glyphs. Set this region's font-size so its cap height renders at ${fp.capHeightPx}px; choose a ${widthClass(fp)} ${weightClass(fp)} face.`);
|
||||
if (!rankId) return;
|
||||
if (fp.capHeightPx < MIN_RANK_CAP_PX) {
|
||||
console.log(`RANK skipped: cap height ${fp.capHeightPx}px is under ${MIN_RANK_CAP_PX}px, too small at comp resolution for a face fingerprint to mean anything. Size this text by its box (${region.px.w}x${region.px.h}px) and inherit face and weight from the nearest measured region.`);
|
||||
return;
|
||||
}
|
||||
const own = parseCandidates(arg('candidates'));
|
||||
const index = loadFontIndex();
|
||||
const { candidates, catalog, source } = selectCandidates(fp, { own, index, n: 25, category: arg('category') });
|
||||
if (index) {
|
||||
const top5 = []; for (const h of catalog) { if (!top5.some((t) => t.family === h.family)) top5.push(h); if (top5.length >= 5) break; }
|
||||
console.log(`CATALOG top-5 by fingerprint: ${top5.map((t) => `${t.family}:${t.weight}`).join(', ')} (from ${index.entries.length} indexed faces${catalog[0] ? `, ${catalog[0].size}px index` : ''}${arg('category') ? `, category ${arg('category')}` : ''})`);
|
||||
console.log(`CANDIDATES ${candidates.length}: ${own.length} yours + ${candidates.length - own.length} nearest in the catalog index`);
|
||||
} else {
|
||||
console.log(`CANDIDATES ${candidates.length}: ${own.length} yours + ${candidates.length - own.length} from the ${widthClass(fp)} shortlist (no catalog index at data/font-index.json)`);
|
||||
}
|
||||
const text = arg('text') || region.text || 'The manuals stop. The forum keeps going.';
|
||||
const transform = arg('transform', fp.allCaps ? 'uppercase' : 'none');
|
||||
const results = await renderCandidates(candidates, text, fp.capHeightPx, { transform });
|
||||
if (!results) {
|
||||
// No browser: the catalog fingerprint index is the ranking. Its top hit is
|
||||
// recorded as the chosen face (source `catalog`) so the spec gate has a
|
||||
// measured choice to close on; without this the gate refused forever and
|
||||
// sessions forced past it or spent ten turns installing Playwright.
|
||||
// font-size is estimated from the cap height at a 0.70 cap/em ratio, the
|
||||
// sans display median; the NOTE says to check one rendered word.
|
||||
if (index && catalog[0]) {
|
||||
const best = catalog[0];
|
||||
const fontSizePx = Math.round(fp.capHeightPx / 0.70);
|
||||
console.log(`RANK unavailable: no browser (playwright or puppeteer) resolvable from this project or the impeccable CLI; the CATALOG order stands as the ranking.`);
|
||||
console.log(`USE font-family: '${best.family}'; font-weight: ${best.weight}; font-size: ${fontSizePx}px;${transform !== 'none' ? ` text-transform: ${transform};` : ''} NOTE font-size is estimated (cap ${fp.capHeightPx}px / 0.70); render one headline word at that size, compare its cap height to the comp crop, and correct the size before building on it.`);
|
||||
region.type.chosen = stampChoice(id, { family: best.family, weight: best.weight, fontSizePx, source: 'catalog', estimatedSize: true });
|
||||
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`RANK unavailable: no browser (playwright or puppeteer) resolvable from this project or the impeccable CLI, and no catalog index. Choose by the MEASURE line: match the width class first, then the weight class; render one headline word against the comp before building on it.`);
|
||||
return;
|
||||
}
|
||||
// Drop faces that never loaded (a weight the family does not ship falls
|
||||
// back to a system face and would rank as that face), then collapse
|
||||
// duplicate renders (two requested weights that resolved to one file).
|
||||
const seenFp = new Set();
|
||||
const rows = results
|
||||
.filter((r) => r.fp && r.loaded)
|
||||
.map((r) => ({ ...r, d: distance(fp, r.fp) }))
|
||||
.filter((r) => Number.isFinite(r.d))
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.filter((r) => { const k = `${r.family}|${r.fp.advX}|${r.fp.advTall}|${r.fp.densTall}|${r.fp.stemW}`; if (seenFp.has(k)) return false; seenFp.add(k); return true; });
|
||||
const dropped = results.filter((r) => !r.loaded).map((r) => `${r.family}:${r.weight}`);
|
||||
if (dropped.length) console.log(`SKIPPED (not available at that weight on Google Fonts): ${dropped.join(', ')}`);
|
||||
const wm = widthMeasure(fp), wt = weightMeasure(fp);
|
||||
const pctDelta = (m, other) => { if (!m || other?.[m.key] == null) return null; return (other[m.key] - m.value) / m.value; };
|
||||
const fmtPct = (v) => (v == null ? 'n/a' : `${v >= 0 ? '+' : ''}${(v * 100).toFixed(0)}%`);
|
||||
for (const r of rows) {
|
||||
console.log(`RANK ${r.family}:${r.weight} distance ${r.d.toFixed(3)} width ${widthClass(r.fp)} (${fmtPct(pctDelta(wm, r.fp))} ${wm?.key || 'advance'}) weight ${weightClass(r.fp)} (${fmtPct(pctDelta(wt, r.fp))} ${wt?.key || 'ink'}) font-size ${r.fontSizePx}px for cap ${fp.capHeightPx}px`);
|
||||
}
|
||||
// proof sheet: comp crop over the top three renders, so the choice is seen, not only scored
|
||||
try {
|
||||
const top = rows.slice(0, 3);
|
||||
const sheet = await renderProofSheet(c, top, text, fp.capHeightPx, transform);
|
||||
if (sheet) {
|
||||
const out = path.join(path.dirname(specPath), 'font-match', `${id}.png`);
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
fs.writeFileSync(out, sheet);
|
||||
console.log(`PROOF ${out} (comp crop, then the top ${top.length} candidates at the comp's cap height; open it before choosing)`);
|
||||
}
|
||||
} catch { /* proof sheet is best-effort */ }
|
||||
const best = rows[0];
|
||||
if (best) {
|
||||
const advice = [];
|
||||
const dw = pctDelta(wm, best.fp), dwt = pctDelta(wt, best.fp);
|
||||
if (dw != null && Math.abs(dw) > 0.1) advice.push(dw > 0 ? 'still too wide: try a more condensed face or a variable font with a wdth axis' : 'still too narrow: try a wider face');
|
||||
// a weight step only helps on a family that ships one; a single-cut display face is what it is
|
||||
const bestEntry = index?.entries.find((e) => e.family === best.family);
|
||||
const variable = bestEntry ? bestEntry.variable : true;
|
||||
if (dwt != null && Math.abs(dwt) > 0.15 && variable) advice.push(dwt > 0 ? `too heavy: drop to weight ${Math.max(100, best.weight - 200)}` : `too light: raise to weight ${Math.min(900, best.weight + 200)}`);
|
||||
console.log(`USE font-family: '${best.family}'; font-weight: ${best.weight}; font-size: ${best.fontSizePx}px;${transform !== 'none' ? ` text-transform: ${transform};` : ''}${advice.length ? ' NOTE ' + advice.join('; ') : ''}`);
|
||||
region.type.chosen = stampChoice(id, { family: best.family, weight: best.weight, fontSizePx: best.fontSizePx, source, fp: compactFp(best.fp) });
|
||||
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
const isMain = (() => {
|
||||
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
|
||||
catch { return false; }
|
||||
})();
|
||||
if (isMain) main().catch((e) => { console.error(`font-match: ${e.message}`); process.exit(1); });
|
||||
@@ -15,22 +15,8 @@
|
||||
* --ref anchors generation on input image(s) via the edits endpoint: pass a
|
||||
* captured screenshot of a representative existing page when comping a new
|
||||
* surface for an established world, so the identity comes from the real UI.
|
||||
*
|
||||
* node generate-image.mjs --plate <region-id> [--spec .impeccable/build/spec.json] [--quality high]
|
||||
*
|
||||
* --plate produces a shipping raster for one raster region of the measured
|
||||
* comp spec (comp-spec.mjs): it crops the region from the approved comp,
|
||||
* sends the crop as the reference with the spec's plate prompt (plus any
|
||||
* --prompt you add), picks the closest supported output size to the region's
|
||||
* aspect, writes the result to the region's `plate` path, embeds the prompt,
|
||||
* and scores the plate against the comp crop with comp-diff so a plate that
|
||||
* does not read as the region is reported (and, with --min, refused) here,
|
||||
* before it lands on the page. In IMPECCABLE_IMAGE_GEN_FAKE mode the plate is
|
||||
* the crop itself at 2x, so offline pipelines can walk the plate gate.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
function arg(name, fallback = null) {
|
||||
@@ -200,154 +186,6 @@ function parseSize(sizeStr) {
|
||||
return [Number(m[1]), Number(m[2])];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plate mode: one raster region of the measured spec -> a shipping plate.
|
||||
// ---------------------------------------------------------------------------
|
||||
const plateId = arg('plate');
|
||||
let plateCtx = null;
|
||||
if (plateId) {
|
||||
const { loadSpec, platePrompt, plateReference, SPEC_PATH } = await import('./comp-spec.mjs');
|
||||
const { decodePng, encodePng, loadRaster } = await import('./lib/png.mjs');
|
||||
const { crop, resize } = await import('./lib/raster.mjs');
|
||||
const specPath = arg('spec', SPEC_PATH);
|
||||
const spec = loadSpec(specPath);
|
||||
if (!spec) { console.error(`generate-image: no spec at ${specPath}; run comp-spec.mjs first`); process.exit(1); }
|
||||
const region = spec.regions.find((r) => r.id === plateId);
|
||||
if (!region) { console.error(`generate-image: no region ${plateId} in ${specPath}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); }
|
||||
if (region.medium !== 'raster') { console.error(`generate-image: region ${plateId} is ${region.medium}, not a plate; set its kind to plate|image|texture in the regions file`); process.exit(1); }
|
||||
let comp;
|
||||
try { comp = loadRaster(spec.comp).image; } catch (e) { console.error(`generate-image: cannot read comp ${spec.comp}: ${e.message}`); process.exit(1); }
|
||||
const ref = plateReference(comp, spec, region);
|
||||
const refPath = path.join(path.dirname(specPath), 'crops', `${region.id}.png`);
|
||||
fs.mkdirSync(path.dirname(refPath), { recursive: true });
|
||||
fs.writeFileSync(refPath, encodePng(ref, { text: { 'impeccable:crop-of': `${spec.comp}#${region.id}` } }));
|
||||
const out = arg('out', region.plate);
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
// Closest supported size to the region's aspect; the page crops the rest
|
||||
// with object-fit. The plates gate demands >= 1.5x the region's width
|
||||
// (capped at 1536), so a square region wider than 682px cannot ship from
|
||||
// 1024x1024: take the 1536-wide landscape frame instead and let cover crop.
|
||||
const aspect = region.px.w / region.px.h;
|
||||
const needW = Math.min(1536, Math.ceil(region.px.w * 1.5));
|
||||
let size = arg('size');
|
||||
if (!size) {
|
||||
if (aspect > 1.2) size = '1536x1024';
|
||||
else if (aspect < 0.83) size = needW > 1024 ? '1536x1024' : '1024x1536';
|
||||
else size = needW > 1024 ? '1536x1024' : '1024x1024';
|
||||
}
|
||||
const extra = arg('prompt') || (arg('prompt-file') ? fs.readFileSync(arg('prompt-file'), 'utf8') : '');
|
||||
// Chroma: an ink-on-ground plate (a line drawing, a figure on flat ground)
|
||||
// is generated on a flat key color and keyed to alpha, so the page's own
|
||||
// ground shows through instead of a second, mismatched paper. Default on
|
||||
// for kind plate when the comp region reads as ink over one flat ground;
|
||||
// --chroma / --no-chroma force it.
|
||||
const wantsChroma = process.argv.includes('--chroma') ? true : process.argv.includes('--no-chroma') ? false : (region.kind === 'plate' && inkOnGround(region));
|
||||
const chromaColor = '#00ff00';
|
||||
const chromaLine = wantsChroma ? ` Render the artwork on a perfectly flat, uniform bright green background (${chromaColor}) that fills every pixel not covered by the artwork; no paper texture, no vignette, no shadow on the green; the green will be removed and the artwork composited onto the page's own surface.` : '';
|
||||
const prompt = [platePrompt(spec, region), extra, chromaLine].filter(Boolean).join(' ');
|
||||
plateCtx = { spec, specPath, region, ref, refPath, out, size, prompt, comp, encodePng, resize, chroma: wantsChroma ? chromaColor : null };
|
||||
if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
|
||||
const up = resize(ref, ref.width * 2, ref.height * 2);
|
||||
fs.writeFileSync(out, encodePng(up, { text: { 'impeccable:prompt': prompt, 'impeccable:fake': '1' } }));
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'fake', plate: region.id, refs: [refPath] }, null, 2));
|
||||
console.log(`PLATE: ${out} (${up.width}x${up.height}, fake 2x crop of region ${region.id}, $0.00, no API call)`);
|
||||
process.exit(0);
|
||||
}
|
||||
// fall through to the real call below with the crop as the single --ref
|
||||
}
|
||||
|
||||
/** A region whose crop is dominated by one ground color with a dark second: ink on ground. */
|
||||
function inkOnGround(region) {
|
||||
const pal = region.palette || [];
|
||||
if (pal.length < 2) return false;
|
||||
return pal[0].coverage >= 0.55;
|
||||
}
|
||||
|
||||
function hexRgb(h) { const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(h); return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [0, 255, 0]; }
|
||||
|
||||
/**
|
||||
* Key a flat color to alpha with a soft edge: pixels within `hard` of the key
|
||||
* go fully transparent, within `soft` fade, and green spill on edge pixels is
|
||||
* pulled toward the ink color. Writes back in place. Returns keyed fraction.
|
||||
*/
|
||||
async function keyChroma(file, keyHex) {
|
||||
const { decodePng, encodePng } = await import('./lib/png.mjs');
|
||||
const img = decodePng(fs.readFileSync(file));
|
||||
const [kr, kg, kb] = hexRgb(keyHex);
|
||||
// sample the actual key from the corners: generators shift the green
|
||||
const corners = [[2, 2], [img.width - 3, 2], [2, img.height - 3], [img.width - 3, img.height - 3]];
|
||||
let sr = 0, sg = 0, sb = 0;
|
||||
for (const [x, y] of corners) { const p = (y * img.width + x) * 4; sr += img.data[p]; sg += img.data[p + 1]; sb += img.data[p + 2]; }
|
||||
const key = [sr / 4, sg / 4, sb / 4];
|
||||
const isGreenish = key[1] > 120 && key[1] > key[0] * 1.4 && key[1] > key[2] * 1.4;
|
||||
const K = isGreenish ? key : [kr, kg, kb];
|
||||
const hard = 60, soft = 120;
|
||||
let keyed = 0;
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
const r = img.data[i], g = img.data[i + 1], b = img.data[i + 2];
|
||||
const d = Math.sqrt((r - K[0]) ** 2 + (g - K[1]) ** 2 + (b - K[2]) ** 2);
|
||||
// also treat "greener than both other channels by a margin" as key, for gradients the generator adds
|
||||
const greenDom = g > 150 && g - Math.max(r, b) > 60;
|
||||
if (d < hard || greenDom) { img.data[i + 3] = 0; keyed++; continue; }
|
||||
if (d < soft) {
|
||||
const a = (d - hard) / (soft - hard);
|
||||
img.data[i + 3] = Math.round(img.data[i + 3] * a);
|
||||
// despill: pull green down to the mean of the others on the fringe
|
||||
const m = (r + b) / 2; img.data[i + 1] = Math.round(g * a + m * (1 - a));
|
||||
}
|
||||
}
|
||||
// keep the tEXt chunks (the embedded prompt written before keying)
|
||||
fs.writeFileSync(file, encodePng(img, { text: img.text && Object.keys(img.text).length ? img.text : null }));
|
||||
return keyed / (img.data.length / 4);
|
||||
}
|
||||
|
||||
async function scorePlate(ctx, outFile) {
|
||||
try {
|
||||
const { compare } = await import('./comp-diff.mjs');
|
||||
const { decodePng } = await import('./lib/png.mjs');
|
||||
let plate = decodePng(fs.readFileSync(outFile));
|
||||
// a keyed plate ships over the page ground: composite it over the region's
|
||||
// sampled ground before scoring, the way it will show
|
||||
if (ctx.chroma) {
|
||||
const { createImage, blit } = await import('./lib/raster.mjs');
|
||||
const g = (ctx.region.palette && ctx.region.palette[0] && ctx.region.palette[0].hex) || '#ffffff';
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(g);
|
||||
const ground = m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16), 255] : [255, 255, 255, 255];
|
||||
const over = createImage(plate.width, plate.height, ground);
|
||||
blit(over, plate, 0, 0);
|
||||
plate = over;
|
||||
}
|
||||
// a plate ships under object-fit: cover, so score it the way it will show
|
||||
const res = compare({ comp: ctx.ref, build: plate, align: 'cover', kind: ctx.region.kind });
|
||||
const s = res.whole;
|
||||
const min = arg('min') ? parseFloat(arg('min')) : null;
|
||||
const line = `PLATE-SCORE ${ctx.region.id} ${(s.overall * 100).toFixed(0)}% against the comp region (structure ${(s.structure * 100).toFixed(0)}%, color ${(s.color * 100).toFixed(0)}%, detail ${(s.detail * 100).toFixed(0)}%)`;
|
||||
console.log(line);
|
||||
const { plateVerdict } = await import('./build-phase.mjs');
|
||||
const v = plateVerdict(ctx.region, s);
|
||||
if (!v.ok) console.log(`PLATE-WARN the plate does not read as region ${ctx.region.id}: ${v.reasons.join('; ')}. Open ${outFile} beside ${ctx.refPath} and regenerate before building on it; the plates gate refuses it as it stands.`);
|
||||
if (min != null && s.overall < min) { console.log(`PLATE-REJECTED below --min ${(min * 100).toFixed(0)}%`); process.exit(3); }
|
||||
} catch (e) {
|
||||
console.log(`PLATE-SCORE unavailable: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// A comp written into .impeccable/mocks/ while a direction is dealt but the
|
||||
// build phases never started is a comp round happening outside the state
|
||||
// file, and every session cut after it resumes with no state to follow. The
|
||||
// roll writes .impeccable/build/pending.json; build-phase.mjs start clears
|
||||
// it. Refuse mock output until start has run (or --force-mock).
|
||||
{
|
||||
const outArg = arg('out') || (plateCtx && plateCtx.out) || '';
|
||||
const intoMocks = /(^|[\\/])\.impeccable[\\/]mocks[\\/]/.test(outArg) && !/[\\/]decision[\\/]/.test(outArg);
|
||||
const pending = fs.existsSync(path.join('.impeccable', 'build', 'pending.json'));
|
||||
const state = fs.existsSync(path.join('.impeccable', 'build', 'state.json'));
|
||||
if (intoMocks && pending && !state && !process.argv.includes('--force-mock')) {
|
||||
console.error(`generate-image: a direction was chosen (concept-seed rolled) but build-phase.mjs start has not run, so this comp would be generated outside the build's state. Run: node ${path.dirname(fileURLToPath(import.meta.url))}/build-phase.mjs start --direction <seed key> --kind <assigned|pick|challenger|canon> first (it opens the comps phase), then generate. --force-mock overrides.`);
|
||||
process.exit(4);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
|
||||
const fakePromptFile = arg('prompt-file');
|
||||
const fakePrompt = fakePromptFile ? fs.readFileSync(fakePromptFile, 'utf8') : arg('prompt');
|
||||
@@ -371,21 +209,21 @@ if (!key) {
|
||||
process.exit(1);
|
||||
}
|
||||
const promptFile = arg('prompt-file');
|
||||
const prompt = plateCtx ? plateCtx.prompt : (promptFile ? fs.readFileSync(promptFile, 'utf8') : arg('prompt'));
|
||||
const out = plateCtx ? plateCtx.out : arg('out');
|
||||
const prompt = promptFile ? fs.readFileSync(promptFile, 'utf8') : arg('prompt');
|
||||
const out = arg('out');
|
||||
if (!prompt || !out) {
|
||||
console.error('generate-image: --prompt (or --prompt-file) and --out are required.');
|
||||
process.exit(1);
|
||||
}
|
||||
const size = plateCtx ? plateCtx.size : arg('size', '1536x1024');
|
||||
const quality = arg('quality', plateCtx ? 'high' : 'medium');
|
||||
const size = arg('size', '1536x1024');
|
||||
const quality = arg('quality', 'medium');
|
||||
// Reference images (--ref, repeatable): route through the edits endpoint,
|
||||
// which accepts input images. This is how a comp for an established world
|
||||
// inherits the real UI's identity from a captured screenshot instead of a
|
||||
// prose paraphrase of it; the prompt then describes the NEW surface and the
|
||||
// reference carries palette, type, and component character.
|
||||
const refs = (() => {
|
||||
const found = plateCtx ? [plateCtx.refPath] : [];
|
||||
const found = [];
|
||||
for (let i = 0; i < process.argv.length; i += 1) {
|
||||
if (process.argv[i] === '--ref' && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) found.push(process.argv[i + 1]);
|
||||
}
|
||||
@@ -431,17 +269,9 @@ 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');
|
||||
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');
|
||||
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
|
||||
} catch { /* embedding is best-effort */ }
|
||||
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.`);
|
||||
}
|
||||
if (plateCtx) await scorePlate(plateCtx, out);
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
||||
|
||||
@@ -35,10 +35,9 @@ import {
|
||||
ensureHookGitExcludes,
|
||||
normalizeIgnoreValue,
|
||||
normalizeIgnoreValueEntries,
|
||||
extractFindingIgnoreValue,
|
||||
} from './hook-lib.mjs';
|
||||
|
||||
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset', 'state', 'apply']);
|
||||
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
|
||||
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
|
||||
'skills/impeccable/scripts/hook-probe.mjs',
|
||||
'skills/impeccable/scripts/hook.mjs',
|
||||
@@ -714,10 +713,6 @@ function addIgnoreValue(cwd, args) {
|
||||
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
|
||||
}
|
||||
|
||||
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
|
||||
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
|
||||
}
|
||||
|
||||
const local = parsed.local;
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
|
||||
// Key on the file scope too: the same rule/value legitimately appears more than
|
||||
@@ -770,161 +765,9 @@ function reset(cwd) {
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// `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.';
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
The design context document's machine channel.
|
||||
|
||||
`state` prints the shared-scope hook state as one JSON object; `apply`
|
||||
reads the full desired state from stdin as JSON and writes it exactly.
|
||||
The document's Hooks page is the caller, through the doc session; the
|
||||
page shows the user every entry it read, so what it sends back is the
|
||||
whole managed set and removals are as deliberate as additions. The
|
||||
union-merging writers above cannot express a removal, which is why
|
||||
`apply` writes the managed detector keys wholesale; unmanaged keys
|
||||
(designSystem, advisoryRules, extensions) survive untouched, and the
|
||||
hook section keeps every field the UI does not manage. An apply may
|
||||
carry a `baseline` field: the state a previous read returned. When the
|
||||
project no longer matches it, the write is refused, so an entry another
|
||||
writer added after that read is never silently clobbered.
|
||||
============================================================ */
|
||||
|
||||
function uiState(cwd) {
|
||||
const detector = readRawDetectorConfig(cwd) || mergeDetectorConfig(null);
|
||||
const hook = readRawHookConfig(cwd);
|
||||
return {
|
||||
enabled: !(hook && hook.enabled === false),
|
||||
ignoreRules: Array.isArray(detector.ignoreRules) ? detector.ignoreRules : [],
|
||||
ignoreFiles: Array.isArray(detector.ignoreFiles) ? detector.ignoreFiles : [],
|
||||
ignoreValues: normalizeIgnoreValueEntries(detector.ignoreValues || []),
|
||||
};
|
||||
}
|
||||
|
||||
const APPLY_LIST_LIMIT = 200;
|
||||
|
||||
function cleanStringList(value, label) {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
||||
if (value.length > APPLY_LIST_LIMIT) throw new Error(`${label} holds more than ${APPLY_LIST_LIMIT} entries`);
|
||||
const out = [];
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== 'string' || !entry.trim()) throw new Error(`${label} entries must be non-empty strings`);
|
||||
if (entry.length > 400) throw new Error(`${label} entry exceeds 400 characters`);
|
||||
if (!out.includes(entry.trim())) out.push(entry.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function cleanIgnoreValues(value) {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) throw new Error('ignoreValues must be an array');
|
||||
if (value.length > APPLY_LIST_LIMIT) throw new Error(`ignoreValues holds more than ${APPLY_LIST_LIMIT} entries`);
|
||||
const out = [];
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw new Error('ignoreValues entries must be objects');
|
||||
const rule = typeof entry.rule === 'string' ? entry.rule.trim() : '';
|
||||
const val = typeof entry.value === 'string' ? entry.value.trim() : '';
|
||||
if (!rule || !val) throw new Error('ignoreValues entries need a rule and a value');
|
||||
const clean = { rule, value: val };
|
||||
if (entry.files !== undefined) {
|
||||
const files = cleanStringList(entry.files, 'ignoreValues files');
|
||||
if (files.length > 0) clean.files = files;
|
||||
}
|
||||
if (val === '*' && !clean.files) throw new Error('a "*" value needs a files scope; use ignoreRules for project-wide');
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) clean.reason = entry.reason.trim().slice(0, 400);
|
||||
out.push(clean);
|
||||
}
|
||||
return normalizeIgnoreValueEntries(out);
|
||||
}
|
||||
|
||||
// Exact-set write for the three managed detector keys. Unlike
|
||||
// writeDetectorConfig this does not union with what is on disk: the caller
|
||||
// read the full state first and hands back the complete set, so an entry
|
||||
// missing from the payload is a removal, not an oversight.
|
||||
function setDetectorExact(cwd, desired) {
|
||||
const filePath = getConfigPath(cwd);
|
||||
const existingRaw = readRawConfigFile(filePath).raw;
|
||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||
const nextHook = stripDetectorKeys(hookSection(existing));
|
||||
const existingDetector = detectorSection(existing) || {};
|
||||
const next = {
|
||||
...existing,
|
||||
detector: {
|
||||
...existingDetector,
|
||||
ignoreRules: desired.ignoreRules,
|
||||
ignoreFiles: desired.ignoreFiles,
|
||||
ignoreValues: desired.ignoreValues,
|
||||
},
|
||||
};
|
||||
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
|
||||
else delete next.hook;
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
||||
}
|
||||
|
||||
// Canonical projection for the baseline comparison: order-stable and
|
||||
// validation-free, because a baseline is a previous read echoed back and
|
||||
// cleaning it could reject entries that are already on disk.
|
||||
function canonState(state) {
|
||||
if (!state || typeof state !== 'object' || Array.isArray(state)) return null;
|
||||
const list = (value) => (Array.isArray(value) ? value.map(String) : []);
|
||||
const values = Array.isArray(state.ignoreValues)
|
||||
? state.ignoreValues.map((entry) => [
|
||||
String(entry?.rule ?? ''),
|
||||
String(entry?.value ?? ''),
|
||||
Array.isArray(entry?.files) ? entry.files.map(String) : null,
|
||||
typeof entry?.reason === 'string' ? entry.reason : null,
|
||||
])
|
||||
: [];
|
||||
return JSON.stringify([state.enabled === true, list(state.ignoreRules), list(state.ignoreFiles), values]);
|
||||
}
|
||||
|
||||
function applyUiState(cwd) {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(fs.readFileSync(0, 'utf-8'));
|
||||
} catch {
|
||||
throw new Error('apply reads one JSON object from stdin');
|
||||
}
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new Error('apply reads one JSON object from stdin');
|
||||
}
|
||||
if (payload.enabled !== undefined && typeof payload.enabled !== 'boolean') {
|
||||
throw new Error('enabled must be a boolean');
|
||||
}
|
||||
if (payload.baseline !== undefined) {
|
||||
const baseline = canonState(payload.baseline);
|
||||
if (baseline === null) throw new Error('baseline must be the state object a previous read returned');
|
||||
if (baseline !== canonState(uiState(cwd))) {
|
||||
throw new Error('the hook config changed on disk after this state was read; read it again and reapply');
|
||||
}
|
||||
}
|
||||
const desired = {
|
||||
ignoreRules: cleanStringList(payload.ignoreRules, 'ignoreRules'),
|
||||
ignoreFiles: cleanStringList(payload.ignoreFiles, 'ignoreFiles'),
|
||||
ignoreValues: cleanIgnoreValues(payload.ignoreValues),
|
||||
};
|
||||
if (typeof payload.enabled === 'boolean' && payload.enabled !== uiState(cwd).enabled) {
|
||||
setEnabled(cwd, payload.enabled);
|
||||
}
|
||||
setDetectorExact(cwd, desired);
|
||||
return JSON.stringify(uiState(cwd));
|
||||
return removed.length
|
||||
? `Reset design hook config and cache (removed: ${removed.join(', ')}).`
|
||||
: 'No hook config or cache to remove. Already at defaults.';
|
||||
}
|
||||
|
||||
function main() {
|
||||
@@ -947,8 +790,6 @@ function main() {
|
||||
case 'ignore-file': out = addIgnoreFile(cwd, rest); break;
|
||||
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
|
||||
case 'reset': out = reset(cwd); break;
|
||||
case 'state': out = JSON.stringify(uiState(cwd)); break;
|
||||
case 'apply': out = applyUiState(cwd); break;
|
||||
}
|
||||
process.stdout.write(out + '\n');
|
||||
} catch (err) {
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
* `cli/engine/detect-antipatterns.mjs` (running from source).
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -211,49 +210,12 @@ export function getLocalConfigPath(cwd) {
|
||||
return path.join(cwd, '.impeccable', 'config.local.json');
|
||||
}
|
||||
|
||||
// Where mutable hook state (cache + pending) lives. Defaults to the
|
||||
// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state
|
||||
// relocates to a per-project subdirectory of that root instead, keyed by a
|
||||
// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's
|
||||
// `~/.claude/projects/` convention), so project roots stay free of tool
|
||||
// artifacts (issue #422). User-authored config (config.json,
|
||||
// config.local.json, design.json) deliberately stays project-local — only
|
||||
// disposable state relocates.
|
||||
// Read from process.env (not runHook's injected env): the cache root is a
|
||||
// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation
|
||||
// switch. Trim guards against stray whitespace in env files; `~/` (or the
|
||||
// Windows `~\` spelling) expands via os.homedir(), and when no home dir can
|
||||
// be determined the expansion is rejected — state falls back to the
|
||||
// project-local default rather than anchoring under the hook process's cwd.
|
||||
// Resolving both sides makes the slug deterministic when callers hand in a
|
||||
// trailing separator or unnormalized cwd. The slug is the readable
|
||||
// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the
|
||||
// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map
|
||||
// to `-x-my-app` and share state), so the digest disambiguates while keeping
|
||||
// the dir name human-scannable.
|
||||
function hookStateDir(cwd) {
|
||||
const raw = process.env.IMPECCABLE_CACHE_ROOT;
|
||||
let root = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') {
|
||||
let home = '';
|
||||
try { home = os.homedir() || ''; } catch { home = ''; }
|
||||
root = home ? path.join(home, root.slice(2)) : '';
|
||||
}
|
||||
if (root) {
|
||||
const resolved = path.resolve(String(cwd));
|
||||
const slug = resolved.replace(/[:\\/.]/g, '-');
|
||||
const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8);
|
||||
return path.join(path.resolve(root), `${slug}-${digest}`);
|
||||
}
|
||||
return path.join(cwd, '.impeccable');
|
||||
}
|
||||
|
||||
export function getCachePath(cwd) {
|
||||
return path.join(hookStateDir(cwd), 'hook.cache.json');
|
||||
return path.join(cwd, '.impeccable', 'hook.cache.json');
|
||||
}
|
||||
|
||||
export function getPendingPath(cwd) {
|
||||
return path.join(hookStateDir(cwd), 'hook.pending.json');
|
||||
return path.join(cwd, '.impeccable', 'hook.pending.json');
|
||||
}
|
||||
|
||||
export function resolveProjectCwd(event, fallback = process.cwd()) {
|
||||
@@ -2160,13 +2122,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// touched-file list for the Stop deep pass, and an already-present
|
||||
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
|
||||
// clean UI edit in a project with no Impeccable footprint, must be a
|
||||
// no-op on disk (issues #344, #305). An existing cache file also counts
|
||||
// as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives
|
||||
// outside the project, so the project dir alone can't carry the marker —
|
||||
// without this, clean-edit editCount bumps would stop persisting the
|
||||
// moment state relocates. Under stock paths the cache sits inside
|
||||
// `.impeccable/`, so the extra check changes nothing there.
|
||||
if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) {
|
||||
// no-op on disk (issues #344, #305).
|
||||
if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
|
||||
persistCache(projectCwd, cache);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// image-gen.mjs — image generation for keyless harnesses.
|
||||
// Playbook: skill/reference/image-api.md (canonical; this help text is not).
|
||||
//
|
||||
// node image-gen.mjs --prompt "..." --out /abs/path.png
|
||||
// [--ref /abs/ref.png] [--width 1408] [--height 1408]
|
||||
//
|
||||
// One CLI, several providers. IMAGE_GEN_PROVIDER in .impeccable/.env picks
|
||||
// the backend:
|
||||
// bfl FLUX (Black Forest Labs). No ref: flux-pro-1.1 text-to-image;
|
||||
// with ref: flux-kontext-max image-to-image, aspect ratio 1:1.
|
||||
// gemini Google Nano Banana (Gemini image models), always square 1:1.
|
||||
// <else> delegates to a project-local .impeccable/image-gen.mjs that
|
||||
// implements this same CLI (see image-api.md for the contract).
|
||||
// When the provider line is missing it is inferred from the key's shape
|
||||
// (Google keys start with "AIza"; anything else is treated as bfl).
|
||||
//
|
||||
// Prints the absolute output path on success; exits non-zero with the
|
||||
// error on stderr on failure. Dependency-free; needs curl and (as a DNS
|
||||
// fallback) dig on PATH.
|
||||
//
|
||||
// Reads IMAGE_GEN_API_KEY from the environment, falling back to
|
||||
// ./.impeccable/.env relative to the working directory, so callers never
|
||||
// need to `source` anything: run it from the project root and it finds
|
||||
// the key itself.
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import dns from "node:dns";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ------------------------------------------------------------------ env
|
||||
|
||||
// The key lives in .impeccable/.env per the document seed flow. Loading it
|
||||
// here (instead of requiring the caller to export it) removes the one setup
|
||||
// step subagents historically forgot, which cost a failed call each time.
|
||||
// IMAGE_API_KEY is accepted as a legacy alias: early seed runs wrote that
|
||||
// name, and those .env files are still in the wild.
|
||||
function loadEnv(...names) {
|
||||
for (const name of names) if (process.env[name]) return process.env[name];
|
||||
const envPath = path.join(process.cwd(), ".impeccable", ".env");
|
||||
if (!fs.existsSync(envPath)) return undefined;
|
||||
const vars = {};
|
||||
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
|
||||
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
|
||||
if (m) vars[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
||||
}
|
||||
for (const name of names) if (vars[name]) return vars[name];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ DNS
|
||||
|
||||
// Sandboxed harnesses (Claude Code among them) often block the default
|
||||
// resolver for the providers' hosts while the hosts stay reachable by IP.
|
||||
// So every request resolves the host here — system resolver first, then
|
||||
// dig against the default, Google, and Cloudflare resolvers — and pins
|
||||
// curl to the IP with --resolve. fetch() is never used; it dies at the
|
||||
// DNS stage.
|
||||
async function resolveIp(hostname) {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const { address } = await dns.promises.lookup(hostname, { family: 4 });
|
||||
if (address) return address;
|
||||
} catch {
|
||||
// fall through to dig
|
||||
}
|
||||
for (const server of [null, "8.8.8.8", "1.1.1.1"]) {
|
||||
try {
|
||||
const args = ["+short", "+time=3", "A", hostname];
|
||||
if (server) args.push(`@${server}`);
|
||||
const ips = execFileSync("dig", args, { encoding: "utf8" })
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => /^\d+\.\d+\.\d+\.\d+$/.test(l));
|
||||
if (ips.length > 0) return ips[ips.length - 1];
|
||||
} catch {
|
||||
// next resolver
|
||||
}
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
throw new Error(`cannot resolve ${hostname} via system resolver, dig, 8.8.8.8, or 1.1.1.1`);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- curl
|
||||
|
||||
// Returns { status, json, text } instead of throwing on HTTP errors, so
|
||||
// callers can branch on 402 (credits) and 429 (rate/quota) rather than
|
||||
// seeing one opaque curl failure. Request bodies always travel via a temp
|
||||
// file: a base64 reference image passed as a literal -d argument overflows
|
||||
// argv (E2BIG) and kills the call before it reaches the network.
|
||||
async function curlJson(url, { method = "GET", headers = {}, body } = {}) {
|
||||
const { hostname } = new URL(url);
|
||||
const ip = await resolveIp(hostname);
|
||||
const args = ["-sS", "--max-time", "180", "--resolve", `${hostname}:443:${ip}`, "-X", method, "-w", "\n%{http_code}"];
|
||||
for (const [k, v] of Object.entries(headers)) args.push("-H", `${k}: ${v}`);
|
||||
let bodyFile;
|
||||
if (body !== undefined) {
|
||||
bodyFile = path.join(os.tmpdir(), `image-gen-body-${process.pid}-${Date.now()}.json`);
|
||||
fs.writeFileSync(bodyFile, body);
|
||||
args.push("-d", `@${bodyFile}`);
|
||||
}
|
||||
args.push(url);
|
||||
try {
|
||||
const out = execFileSync("curl", args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
||||
const nl = out.lastIndexOf("\n");
|
||||
const status = parseInt(out.slice(nl + 1), 10);
|
||||
const text = out.slice(0, nl);
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
// non-JSON body (edge HTML error page); callers see json === null
|
||||
}
|
||||
return { status, json, text };
|
||||
} finally {
|
||||
if (bodyFile) fs.rmSync(bodyFile, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function download(url, outPath) {
|
||||
const { hostname } = new URL(url);
|
||||
let lastErr;
|
||||
// Re-resolve on every attempt: CDN delivery hosts are the flakiest to
|
||||
// resolve, and a fresh IP is usually what fixes a failure.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const ip = await resolveIp(hostname);
|
||||
execFileSync("curl", ["-sS", "-f", "--max-time", "60", "--resolve", `${hostname}:443:${ip}`, "-o", outPath, url]);
|
||||
if (fs.existsSync(outPath) && fs.statSync(outPath).size > 0) return;
|
||||
lastErr = new Error("download produced an empty file");
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
await sleep(2000 * (attempt + 1));
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- args
|
||||
|
||||
function getArg(name, def) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i >= 0 ? process.argv[i + 1] : def;
|
||||
}
|
||||
|
||||
function fail(msg) {
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ bfl
|
||||
|
||||
// FLUX is asynchronous: submit returns a polling_url, poll until Ready,
|
||||
// download the signed result URL inside its 10-minute expiry. Transient
|
||||
// failures are absorbed internally so a network blip costs this script
|
||||
// seconds instead of costing a caller one of its generation attempts.
|
||||
// Only two failures are final on the spot: 402 means the account is out
|
||||
// of credits (a human must top up; retrying is pointless), and a
|
||||
// moderation status means the prompt itself must change.
|
||||
async function generateBfl({ apiKey, prompt, ref, width, height, out }) {
|
||||
for (const [label, v] of [["width", width], ["height", height]]) {
|
||||
if (Number.isNaN(v) || v < 256 || v > 1440 || v % 32 !== 0) {
|
||||
fail(`${label} ${v} out of range: BFL takes 256-1440 in multiples of 32`);
|
||||
}
|
||||
}
|
||||
|
||||
const base = "https://api.bfl.ai";
|
||||
let endpoint, body;
|
||||
if (ref) {
|
||||
endpoint = "/v1/flux-kontext-max";
|
||||
body = { prompt, input_image: fs.readFileSync(ref).toString("base64"), aspect_ratio: "1:1", output_format: "png" };
|
||||
} else {
|
||||
endpoint = "/v1/flux-pro-1.1";
|
||||
body = { prompt, width, height, output_format: "png" };
|
||||
}
|
||||
|
||||
const authHeaders = { "x-key": apiKey, "Content-Type": "application/json", accept: "application/json" };
|
||||
let submit;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
submit = await curlJson(base + endpoint, { method: "POST", headers: authHeaders, body: JSON.stringify(body) });
|
||||
} catch (e) {
|
||||
submit = { status: 0, json: null, text: e.message };
|
||||
}
|
||||
if (submit.status === 200 && submit.json?.polling_url) break;
|
||||
if (submit.status === 402) fail("BFL account is out of credits; add credits at dashboard.bfl.ai and re-run");
|
||||
if (submit.status === 401 || submit.status === 403) fail(`BFL rejected the key (HTTP ${submit.status}): check IMAGE_GEN_API_KEY`);
|
||||
if (attempt >= 2) fail(`Submit failed after 3 attempts (last HTTP ${submit.status}): ${submit.text?.slice(0, 300)}`);
|
||||
// 429 is the active-task cap (24 tasks; 6 for kontext-max): wait longer.
|
||||
await sleep(submit.status === 429 ? 10000 : 2000 * (attempt + 1));
|
||||
}
|
||||
|
||||
// Poll the returned polling_url (never a reconstructed one; the global
|
||||
// endpoint requires it). Tolerate a few consecutive transient poll
|
||||
// failures — the task keeps running server-side regardless.
|
||||
let result;
|
||||
let pollFailures = 0;
|
||||
for (let i = 0; i < 150; i++) {
|
||||
await sleep(2000);
|
||||
let poll;
|
||||
try {
|
||||
poll = await curlJson(submit.json.polling_url, { headers: { "x-key": apiKey, accept: "application/json" } });
|
||||
} catch {
|
||||
poll = null;
|
||||
}
|
||||
if (!poll || poll.status >= 500 || !poll.json) {
|
||||
if (++pollFailures >= 5) fail("Polling failed 5 times in a row; giving up");
|
||||
continue;
|
||||
}
|
||||
pollFailures = 0;
|
||||
if (poll.json.status === "Ready") {
|
||||
result = poll.json.result;
|
||||
break;
|
||||
}
|
||||
if (["Error", "Failed", "Content Moderated", "Request Moderated", "Task not found"].includes(poll.json.status)) {
|
||||
fail(`Generation failed with status "${poll.json.status}": ${JSON.stringify(poll.json).slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
if (!result) fail("Timed out waiting for the generation (5 minutes)");
|
||||
|
||||
// The sample URL is signed and expires after 10 minutes; download now.
|
||||
await download(result.sample, out);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- gemini
|
||||
|
||||
// Nano Banana is synchronous: one generateContent call returns the image
|
||||
// as base64 in the response, no polling, no delivery CDN. The aspect ratio
|
||||
// is pinned 1:1 in imageConfig, so output is always square regardless of
|
||||
// --width/--height (Gemini picks its own pixel size per tier; the pipeline
|
||||
// only requires square). Moderation shows up as a response with no image
|
||||
// part plus a block reason, not as an HTTP error.
|
||||
async function generateGemini({ apiKey, prompt, ref, out }) {
|
||||
// IMAGE_GEN_MODEL overrides for users on a different tier; the default
|
||||
// is the high-volume Nano Banana model.
|
||||
let model = loadEnv("IMAGE_GEN_MODEL") || "gemini-3.1-flash-image";
|
||||
const parts = [{ text: prompt }];
|
||||
if (ref) parts.push({ inlineData: { mimeType: "image/png", data: fs.readFileSync(ref).toString("base64") } });
|
||||
const body = JSON.stringify({
|
||||
contents: [{ parts }],
|
||||
generationConfig: { responseModalities: ["IMAGE"], imageConfig: { aspectRatio: "1:1" } },
|
||||
});
|
||||
const headers = { "x-goog-api-key": apiKey, "Content-Type": "application/json" };
|
||||
const urlFor = (m) => `https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent`;
|
||||
|
||||
let res;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
res = await curlJson(urlFor(model), { method: "POST", headers, body });
|
||||
} catch (e) {
|
||||
res = { status: 0, json: null, text: e.message };
|
||||
}
|
||||
if (res.status === 200) break;
|
||||
const msg = res.json?.error?.message || res.text?.slice(0, 300) || "";
|
||||
if (res.status === 400 && /API key not valid/i.test(msg)) fail(`Gemini rejected the key: check IMAGE_GEN_API_KEY (${msg.slice(0, 200)})`);
|
||||
if (res.status === 401 || res.status === 403) fail(`Gemini rejected the key (HTTP ${res.status}): ${msg.slice(0, 200)}`);
|
||||
// Model ids drift between stable and -preview suffixes as Google
|
||||
// promotes them; try the sibling name once before giving up.
|
||||
if (res.status === 404 && !model.endsWith("-preview")) {
|
||||
model = `${model}-preview`;
|
||||
continue;
|
||||
}
|
||||
if (res.status === 429 && attempt >= 4) fail(`Gemini quota or rate limit exhausted after 5 attempts: ${msg.slice(0, 200)}; check the plan and billing for this key`);
|
||||
if (attempt >= 4) fail(`Gemini call failed after 5 attempts (last HTTP ${res.status}): ${msg.slice(0, 300)}`);
|
||||
await sleep(res.status === 429 ? 15000 : 2000 * (attempt + 1));
|
||||
}
|
||||
|
||||
const blocked = res.json?.promptFeedback?.blockReason;
|
||||
if (blocked) fail(`Prompt was moderated (${blocked}); reword the prompt and re-run`);
|
||||
const cand = res.json?.candidates?.[0];
|
||||
const imgPart = cand?.content?.parts?.find((p) => p.inlineData?.data || p.inline_data?.data);
|
||||
if (!imgPart) {
|
||||
const reason = cand?.finishReason || "no image part in the response";
|
||||
fail(`Generation returned no image (${reason}); reword the prompt and re-run`);
|
||||
}
|
||||
// Gemini often returns JPEG bytes whatever the caller's filename says,
|
||||
// and the pipelines' compile steps decode PNG only, so convert here
|
||||
// rather than making every caller rediscover the mismatch.
|
||||
writeAsPng(Buffer.from(imgPart.inlineData?.data || imgPart.inline_data.data, "base64"), out);
|
||||
}
|
||||
|
||||
// Writes image bytes to `out` as a real PNG. PNG input passes through;
|
||||
// anything else (JPEG, WebP) is converted with the first available system
|
||||
// tool: sips ships with macOS, ImageMagick and ffmpeg cover Linux.
|
||||
function writeAsPng(buf, out) {
|
||||
if (buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
fs.writeFileSync(out, buf);
|
||||
return;
|
||||
}
|
||||
const tmp = path.join(os.tmpdir(), `image-gen-raw-${process.pid}-${Date.now()}.img`);
|
||||
fs.writeFileSync(tmp, buf);
|
||||
const converters = [
|
||||
["sips", ["-s", "format", "png", tmp, "--out", out]],
|
||||
["magick", [tmp, `png:${out}`]],
|
||||
["convert", [tmp, `png:${out}`]],
|
||||
["ffmpeg", ["-y", "-i", tmp, out]],
|
||||
];
|
||||
try {
|
||||
for (const [cmd, args] of converters) {
|
||||
try {
|
||||
execFileSync(cmd, args, { stdio: "ignore" });
|
||||
if (fs.existsSync(out) && fs.statSync(out).size > 0) return;
|
||||
} catch {
|
||||
// tool missing or failed; try the next one
|
||||
}
|
||||
}
|
||||
fail("Provider returned non-PNG image bytes and no converter is available (tried sips, magick, convert, ffmpeg); install one and re-run");
|
||||
} finally {
|
||||
fs.rmSync(tmp, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- main
|
||||
|
||||
const prompt = getArg("prompt");
|
||||
const out = getArg("out");
|
||||
const ref = getArg("ref");
|
||||
// 1408 is the default square: comfortably under BFL's 1440 cap and
|
||||
// divisible by 32. Gemini ignores it (aspect ratio 1:1 pins its square).
|
||||
const width = parseInt(getArg("width", "1408"), 10);
|
||||
const height = parseInt(getArg("height", "1408"), 10);
|
||||
const apiKey = loadEnv("IMAGE_GEN_API_KEY", "IMAGE_API_KEY");
|
||||
// Users and earlier runs write provider names loosely ("flux" for bfl,
|
||||
// "nano-banana" for gemini); normalize the known spellings instead of
|
||||
// failing on them. Google API keys start with "AIza" (classic) or "AQ."
|
||||
// (newer), so a missing provider line is recoverable from the key itself.
|
||||
const PROVIDER_ALIASES = {
|
||||
bfl: "bfl", flux: "bfl", "black-forest-labs": "bfl",
|
||||
gemini: "gemini", google: "gemini", "nano-banana": "gemini", nanobanana: "gemini",
|
||||
};
|
||||
const looksGoogle = apiKey?.startsWith("AIza") || apiKey?.startsWith("AQ.");
|
||||
const rawProvider = (loadEnv("IMAGE_GEN_PROVIDER") || (looksGoogle ? "gemini" : "bfl")).toLowerCase();
|
||||
const provider = PROVIDER_ALIASES[rawProvider] || rawProvider;
|
||||
|
||||
if (!prompt || !out) fail("Usage: --prompt <p> --out <abs path> [--ref <abs path>] [--width n] [--height n]");
|
||||
|
||||
if (provider !== "bfl" && provider !== "gemini") {
|
||||
// Unknown provider: hand the same argv to a project-local wrapper that
|
||||
// implements this CLI. The env guard stops a copied shipped script from
|
||||
// delegating to itself forever.
|
||||
const custom = path.join(process.cwd(), ".impeccable", "image-gen.mjs");
|
||||
if (process.env.IMPECCABLE_IMAGE_GEN_DELEGATED || !fs.existsSync(custom)) {
|
||||
fail(`Unknown IMAGE_GEN_PROVIDER "${provider}" and no ${custom}; supported providers are bfl and gemini, or write that file implementing the same CLI (see reference/image-api.md)`);
|
||||
}
|
||||
const child = spawnSync(process.execPath, [custom, ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, IMPECCABLE_IMAGE_GEN_DELEGATED: "1" },
|
||||
});
|
||||
process.exit(child.status ?? 1);
|
||||
}
|
||||
|
||||
if (!apiKey) fail("Missing IMAGE_GEN_API_KEY (environment or ./.impeccable/.env)");
|
||||
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
if (provider === "gemini") await generateGemini({ apiKey, prompt, ref, out });
|
||||
else await generateBfl({ apiKey, prompt, ref, width, height, out });
|
||||
console.log(path.resolve(out));
|
||||
@@ -329,37 +329,47 @@ 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 inlineMatches = [...joined.matchAll(/\*\*(The [^*]+?Rule)\.\*\*/g)];
|
||||
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 });
|
||||
}
|
||||
for (let i = 0; i < inlineMatches.length; i++) {
|
||||
const match = inlineMatches[i];
|
||||
const bodyEnd = inlineMatches[i + 1]?.index ?? joined.length;
|
||||
const mm = inlineMatches[i];
|
||||
const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;
|
||||
const body = joined
|
||||
.slice(match.index + match[0].length, bodyEnd)
|
||||
.slice(mm.end, bodyEnd)
|
||||
.replace(/\n##[^\n]*$/s, '')
|
||||
.replace(/\n###[^\n]*$/s, '')
|
||||
.trim();
|
||||
// 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 });
|
||||
const name = stripBold(mm.name).trim();
|
||||
seen.add(name.toLowerCase());
|
||||
rules.push({ name, body: stripBold(body) });
|
||||
}
|
||||
|
||||
// Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the
|
||||
// bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.
|
||||
for (const subsection of splitSubsections(lines).slice(1)) {
|
||||
const headerName = stripBold(subsection.name).replace(/["“”]/g, '').trim();
|
||||
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();
|
||||
if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;
|
||||
if (seen.has(headerName.toLowerCase())) continue;
|
||||
|
||||
const body = stripBold(subsection.lines.join('\n').replace(/\n+/g, ' ')).trim();
|
||||
if (body) addRule(headerName, body);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// Style C (Stitch bullet form): "* **The Layering Principle:** body"
|
||||
@@ -369,7 +379,9 @@ 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;
|
||||
addRule(nameRaw, stripBold(mm[2]).trim());
|
||||
if (seen.has(nameRaw.toLowerCase())) continue;
|
||||
seen.add(nameRaw.toLowerCase());
|
||||
rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });
|
||||
}
|
||||
|
||||
return rules;
|
||||
|
||||
@@ -1,564 +0,0 @@
|
||||
/**
|
||||
* font-fingerprint: size-invariant, text-robust shape features for lettering
|
||||
* in a raster (a comp crop or a rendered sample). fingerprint(img) returns the
|
||||
* feature vector; distance(a, b) compares two vectors over noise-normalized,
|
||||
* weighted features. Used by font-match.mjs (comp measurement and ranking)
|
||||
* and by the catalog index build (scripts/build-font-index.mjs at the repo root). Depends only on
|
||||
* lib/image-metrics.mjs and lib/raster.mjs.
|
||||
*
|
||||
* Every measure is taken per text line and normalized by R, the line's
|
||||
* reference height (median of the tallest column heights above the baseline:
|
||||
* the cap line on an all-caps line, the ascender line on a mixed line), so
|
||||
* the same face gives the same numbers at any point size; per-glyph measures
|
||||
* are medians so the numbers survive a change of text. Small crops are
|
||||
* upsampled (bilinear) so R is at least 24px, and stroke runs are measured
|
||||
* with antialiased edge pixels counted by coverage, so stem widths do not
|
||||
* fatten at small sizes.
|
||||
*
|
||||
* Features (all in R units unless noted; null when not measurable):
|
||||
* advance/advTall/advX median glyph width over baseline glyphs / tall glyphs / x-height glyphs
|
||||
* advCV spread of glyph widths (std/median): mono ~0.15, sans ~0.3, script > 0.5
|
||||
* gap median inter-glyph gap
|
||||
* xRatio x-line / R (null on all-caps lines)
|
||||
* descRatio descender depth (90th pct)
|
||||
* stemW median horizontal ink run in the x band (stem width)
|
||||
* contrast stem width / median thin (vertical) run: didone high, grotesque ~1
|
||||
* serif foot width / mid-stem width on stems that reach the baseline
|
||||
* roundFrac fraction of glyphs with bbox aspect > 0.9
|
||||
* densTall / densX ink / bbox area for tall / x-height glyphs (weight)
|
||||
* runDensity horizontal ink runs per row per R of line width (stroke busyness)
|
||||
* vprof0..9 normalized vertical ink profile from 0.35R below baseline to 1.05R above
|
||||
* hrun25/50/75/90 quantiles of horizontal run lengths over the letter body
|
||||
* vrun25/50/75/90 quantiles of vertical run lengths over the whole line
|
||||
* colq25/75 quantiles of column heights above the baseline
|
||||
* wq25/75 quantiles of glyph widths
|
||||
* Also returned: lines, glyphs, capHeightPx (R in source pixels), allCaps, inkIsDark,
|
||||
* upsampled, weight (densTall, so v1 callers keep a weight field).
|
||||
*/
|
||||
import { toGray } from './image-metrics.mjs';
|
||||
import { resize } from './raster.mjs';
|
||||
|
||||
function otsu(gray) {
|
||||
const hist = new Float64Array(256);
|
||||
for (let i = 0; i < gray.data.length; i++) hist[Math.max(0, Math.min(255, Math.round(gray.data[i])))]++;
|
||||
const total = gray.data.length;
|
||||
let sum = 0; for (let i = 0; i < 256; i++) sum += i * hist[i];
|
||||
let sumB = 0, wB = 0, best = 0, thr = 128;
|
||||
for (let t = 0; t < 256; t++) {
|
||||
wB += hist[t]; if (!wB) continue;
|
||||
const wF = total - wB; if (!wF) break;
|
||||
sumB += t * hist[t];
|
||||
const mB = sumB / wB, mF = (sum - sumB) / wF;
|
||||
const between = wB * wF * (mB - mF) ** 2;
|
||||
if (between > best) { best = between; thr = t; }
|
||||
}
|
||||
return thr;
|
||||
}
|
||||
|
||||
const med = (a) => { if (!a.length) return null; const s = [...a].sort((p, q) => p - q); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
|
||||
const pct = (a, p) => { if (!a.length) return null; const s = [...a].sort((p, q) => p - q); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; };
|
||||
const mean = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : null);
|
||||
|
||||
/** Binarize; returns { W, H, ink: Uint8Array, inkIsDark }. */
|
||||
function binarize(img) {
|
||||
const g = toGray(img);
|
||||
let thr = otsu(g);
|
||||
let dark = 0; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++;
|
||||
// a two-level raster (no antialiasing) puts the Otsu threshold on the dark
|
||||
// level itself; step it up so that level counts as ink
|
||||
if (!dark) { thr += 1; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++; }
|
||||
const inkIsDark = dark <= g.data.length / 2;
|
||||
const ink = new Uint8Array(g.data.length);
|
||||
let sI = 0, nI = 0, sG = 0, nG = 0;
|
||||
for (let i = 0; i < g.data.length; i++) {
|
||||
const on = (inkIsDark ? g.data[i] < thr : g.data[i] >= thr) ? 1 : 0;
|
||||
ink[i] = on;
|
||||
if (on) { sI += g.data[i]; nI++; } else { sG += g.data[i]; nG++; }
|
||||
}
|
||||
const inkLevel = nI ? sI / nI : (inkIsDark ? 0 : 255), groundLevel = nG ? sG / nG : (inkIsDark ? 255 : 0);
|
||||
// coverage per pixel: 0 = ground, 1 = ink, linear between the two class means, so
|
||||
// antialiased edge pixels count fractionally and stroke widths do not fatten at small sizes
|
||||
const covA = new Float32Array(g.data.length);
|
||||
const den = groundLevel - inkLevel || 1;
|
||||
for (let i = 0; i < g.data.length; i++) covA[i] = Math.max(0, Math.min(1, (groundLevel - g.data[i]) / den));
|
||||
const cov = (i) => covA[i];
|
||||
return { W: g.width, H: g.height, ink, inkIsDark, cov, covA };
|
||||
}
|
||||
|
||||
/** Text lines from the row-ink profile (same rules as font-match v1). */
|
||||
function findLines(bin) {
|
||||
const { W, H, ink } = bin;
|
||||
// Columns inked top to bottom (a rule, a black margin, a page edge) span
|
||||
// every line and would fuse them into one run: leave them out of the row
|
||||
// profile. Lettering never fills a column for more than ~85% of the crop.
|
||||
const colInk = new Uint32Array(W);
|
||||
for (let y = 0; y < H; y++) { const o = y * W; for (let x = 0; x < W; x++) colInk[x] += ink[o + x]; }
|
||||
const colOk = new Uint8Array(W);
|
||||
let okCount = 0;
|
||||
for (let x = 0; x < W; x++) { if (colInk[x] < H * 0.85) { colOk[x] = 1; okCount++; } }
|
||||
if (!okCount) return { lines: [], rowInk: new Uint32Array(H) };
|
||||
const rowInk = new Uint32Array(H);
|
||||
for (let y = 0; y < H; y++) { let c = 0; const o = y * W; for (let x = 0; x < W; x++) if (colOk[x]) c += ink[o + x]; rowInk[y] = c; }
|
||||
const floor = Math.max(1, W * 0.004);
|
||||
const runs = [];
|
||||
let y = 0;
|
||||
while (y < H) {
|
||||
if (rowInk[y] > floor) {
|
||||
const y0 = y; while (y < H && (rowInk[y] > floor || (y + 1 < H && rowInk[y + 1] > floor))) y++;
|
||||
if (y - y0 >= 4) runs.push({ y0, y1: y });
|
||||
} else y++;
|
||||
}
|
||||
const lines = [];
|
||||
for (const run of runs) {
|
||||
let peak = 0; for (let yy = run.y0; yy < run.y1; yy++) peak = Math.max(peak, rowInk[yy]);
|
||||
const valley = peak * 0.15;
|
||||
let start = run.y0, inValley = false, valleyStart = 0;
|
||||
for (let yy = run.y0; yy < run.y1; yy++) {
|
||||
const low = rowInk[yy] < valley;
|
||||
if (low && !inValley) { inValley = true; valleyStart = yy; }
|
||||
if (!low && inValley) {
|
||||
inValley = false;
|
||||
if (yy - valleyStart >= 3 && valleyStart - start >= 4) { lines.push({ y0: start, y1: valleyStart, run }); start = yy; }
|
||||
}
|
||||
}
|
||||
if (run.y1 - start >= 4) lines.push({ y0: start, y1: run.y1, run });
|
||||
}
|
||||
// A piece split off inside one run with a fraction of the ink of the text
|
||||
// lines is not a line: a thin band of ascenders or tittles above the x band
|
||||
// (few letters reach it, so the valley rule fires) or a stray rule. Ascender
|
||||
// bands merge back into the line below them; anything else is dropped.
|
||||
for (const ln of lines) { let m = 0; for (let yy = ln.y0; yy < ln.y1; yy++) m += rowInk[yy]; ln.mass = m; }
|
||||
// A drawing or photo sharing the crop with body copy is one tall, massive
|
||||
// 'line' that would carry maxMass and drop every real line under the 30%
|
||||
// rule (a 461x307 thread crop measured as one 160px 'cap' off a
|
||||
// carburetor drawing). When several lines exist, ones far taller than the
|
||||
// median are not lettering: leave them out of the mass reference and out
|
||||
// of the result.
|
||||
// The median is taken over lines carrying real mass (rule slivers and
|
||||
// tittles do not vote), and needs three of them: two 145px headline lines
|
||||
// above a 26px artist line were dropped as 'tall' against a median pulled
|
||||
// to 28 by three slivers.
|
||||
const massMax = Math.max(1, ...lines.map((l) => l.mass));
|
||||
const real = lines.filter((l) => l.mass >= massMax * 0.05);
|
||||
if (real.length >= 3) {
|
||||
const hs = real.map((l) => l.y1 - l.y0).sort((a, b) => a - b);
|
||||
const medH = hs[Math.floor(hs.length / 2)];
|
||||
for (const ln of lines) if (ln.y1 - ln.y0 > medH * 3) ln.tall = true;
|
||||
}
|
||||
const maxMass = Math.max(0, ...lines.filter((l) => !l.tall).map((l) => l.mass));
|
||||
const merged = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const ln = lines[i];
|
||||
if (ln.tall) continue;
|
||||
if (ln.mass >= maxMass * 0.3) { merged.push({ y0: ln.y0, y1: ln.y1, mass: ln.mass }); continue; }
|
||||
const next = lines[i + 1];
|
||||
if (next && next.run === ln.run && next.mass >= maxMass * 0.3 && (ln.y1 - ln.y0) <= (next.y1 - next.y0) * 0.5) { next.y0 = ln.y0; }
|
||||
}
|
||||
return { lines: merged, rowInk };
|
||||
}
|
||||
|
||||
/** Feature names in fingerprint order (used by distance). */
|
||||
const VBINS = 10, HQ = [0.25, 0.5, 0.75, 0.9];
|
||||
export const FEATURES = ['advance', 'advTall', 'advX', 'advCV', 'gap', 'xRatio', 'descRatio', 'stemW', 'contrast', 'serif', 'roundFrac', 'densTall', 'densX', 'runDensity',
|
||||
...Array.from({ length: VBINS }, (_, i) => `vprof${i}`), ...HQ.map((q) => `hrun${Math.round(q * 100)}`), ...HQ.map((q) => `vrun${Math.round(q * 100)}`), 'colq25', 'colq75', 'wq25', 'wq75'];
|
||||
|
||||
/** Center of the densest window of width tol in a list of values, and its count. */
|
||||
function modeOf(vals, tol) {
|
||||
let best = null, bestC = -1;
|
||||
const s = [...vals].sort((a, b) => a - b);
|
||||
let j = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
while (s[i] - s[j] > tol) j++;
|
||||
const c = i - j + 1;
|
||||
if (c > bestC) { bestC = c; best = (s[i] + s[j]) / 2; }
|
||||
}
|
||||
return { v: best, n: bestC };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-line vertical metrics from column extrema, which do not need glyphs to
|
||||
* be separable. baseline = mode of column bottoms. R (the reference height)
|
||||
* is the top line of the tallest cluster: the cap line on an all-caps line,
|
||||
* the ascender line (or the cap line when caps are taller) on a mixed line.
|
||||
* The x-line is a second mode of column heights well below R; when there is
|
||||
* none the line is read as all-caps.
|
||||
*/
|
||||
function lineMetrics(bin, ln) {
|
||||
const { W, ink, cov } = bin;
|
||||
const cols = [];
|
||||
for (let x = 0; x < W; x++) {
|
||||
let top = -1, bot = -1;
|
||||
for (let yy = ln.y0; yy < ln.y1; yy++) if (ink[yy * W + x]) { if (top < 0) top = yy; bot = yy + 1; }
|
||||
if (top < 0) continue;
|
||||
// sub-pixel edges from the antialiased boundary pixel's coverage
|
||||
const t = top > 0 ? top - cov((top - 1) * W + x) : top;
|
||||
const b = bot < bin.H ? bot + cov(bot * W + x) : bot;
|
||||
cols.push({ x, top: t, bot: b });
|
||||
}
|
||||
if (cols.length < 8) return null;
|
||||
const roughH = pct(cols.map((c) => c.bot - c.top), 0.9);
|
||||
const tol = Math.max(1, Math.round(roughH * 0.04));
|
||||
const baseF = modeOf(cols.map((c) => c.bot), tol).v;
|
||||
const base = Math.round(baseF);
|
||||
const hs = cols.filter((c) => c.bot <= baseF + tol * 1.5).map((c) => baseF - c.top).filter((h) => h > 0);
|
||||
if (hs.length < 8) return null;
|
||||
const hMaxAbs = pct(hs, 0.995);
|
||||
const topCluster = hs.filter((h) => h >= hMaxAbs * 0.94);
|
||||
const R = med(topCluster);
|
||||
if (!R || R < 4) return null;
|
||||
const lowHs = hs.filter((h) => h >= R * 0.3 && h <= R * 0.86);
|
||||
let xh = null;
|
||||
if (lowHs.length >= Math.max(6, hs.length * 0.12)) {
|
||||
const m = modeOf(lowHs, tol);
|
||||
if (m.n >= Math.max(4, lowHs.length * 0.25)) xh = m.v;
|
||||
}
|
||||
const dsc = cols.filter((c) => c.bot > baseF + tol * 1.5 && c.top < baseF - R * 0.3).map((c) => (c.bot - baseF) / R);
|
||||
const descRatio = dsc.length >= 4 ? pct(dsc, 0.9) : null;
|
||||
return { base, R, cap: R, xh, descRatio, tol, xL: cols[0].x, xR: cols[cols.length - 1].x + 1, hs, ln };
|
||||
}
|
||||
|
||||
/** Glyph boxes: column runs of ink inside the x band, so ascender/descender bridges do not merge letters. */
|
||||
function segment(bin, ln, m) {
|
||||
const { W, ink } = bin;
|
||||
const bandTop = Math.max(ln.y0, Math.round(m.base - (m.xh || m.cap * 0.6)));
|
||||
const bandH = m.base - bandTop;
|
||||
const thr = 1;
|
||||
const colBand = new Uint32Array(W);
|
||||
for (let yy = bandTop; yy < m.base; yy++) { const o = yy * W; for (let x = m.xL; x < m.xR; x++) colBand[x] += ink[o + x]; }
|
||||
const runs = [];
|
||||
let x = m.xL;
|
||||
while (x < m.xR) {
|
||||
if (colBand[x] >= thr) { const x0 = x; while (x < m.xR && colBand[x] >= thr) x++; runs.push({ x0, x1: x }); } else x++;
|
||||
}
|
||||
const out = [];
|
||||
for (const r of runs) {
|
||||
let top = -1, bot = -1, area = 0;
|
||||
for (let yy = ln.y0; yy < ln.y1; yy++) {
|
||||
let c = 0, cv = 0; const o = yy * W; for (let xx = r.x0; xx < r.x1; xx++) { c += ink[o + xx]; cv += bin.covA[o + xx]; }
|
||||
if (c) { if (top < 0) top = yy; bot = yy + 1; }
|
||||
area += cv;
|
||||
}
|
||||
if (top >= 0) out.push({ x0: r.x0, x1: r.x1, w: r.x1 - r.x0, top, bot, h: bot - top, area });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function measure(bin, lines) {
|
||||
const { W, H, ink, covA } = bin;
|
||||
// run lengths with the antialiased edge pixels counted by coverage
|
||||
const hLen = (o, x0, x1) => { let s = 0; for (let x = Math.max(0, x0 - 1); x < Math.min(W, x1 + 1); x++) s += covA[o + x]; return s; };
|
||||
const vLen = (x, y0, y1) => { let s = 0; for (let y = Math.max(0, y0 - 1); y < Math.min(H, y1 + 1); y++) s += covA[y * W + x]; return s; };
|
||||
let glyphN = 0;
|
||||
const per = { xh: [], desc: [], runDensity: [] };
|
||||
let allCapsLines = 0;
|
||||
const vprof = new Float64Array(VBINS); const hruns = [], vruns = [], colHs = [], widths = [];
|
||||
const advTall = [], advAll = [], advX = [], gaps = [], stems = [], thins = [], serifR = [], round = [], densTall = [], densX = [];
|
||||
let capSum = 0, capN = 0;
|
||||
// One crop, one case. In a multi-line all-caps headline one line can grow a
|
||||
// spurious x-height from crossbars (the A and E arms of "JAPANESE" at 0.32R)
|
||||
// while its neighbours report none; that line then measures its stems and
|
||||
// its x band on the crossbar zone. Lines vote: when most lines see no
|
||||
// x-height, none does.
|
||||
const metrics = lines.map((ln) => lineMetrics(bin, ln)).filter(Boolean);
|
||||
if (metrics.length >= 2) {
|
||||
const withX = metrics.filter((m) => m.xh).length;
|
||||
if (withX * 2 <= metrics.length) for (const m of metrics) m.xh = null;
|
||||
}
|
||||
for (const m of metrics) {
|
||||
const ln = m.ln;
|
||||
const { base, cap, xh, tol, xL, xR } = m;
|
||||
capSum += cap; capN++;
|
||||
if (xh) per.xh.push(xh / cap);
|
||||
if (!xh) allCapsLines++;
|
||||
if (m.descRatio != null) per.desc.push(m.descRatio);
|
||||
for (const h of m.hs) colHs.push(h / cap);
|
||||
// vertical ink profile from 0.35R below the baseline to 1.05R above, VBINS bins
|
||||
for (let yy = ln.y0; yy < ln.y1; yy++) {
|
||||
const u = (base - yy - 0.5) / cap; // height above baseline in R units
|
||||
const bi = Math.floor((u + 0.35) / 1.4 * VBINS);
|
||||
if (bi < 0 || bi >= VBINS) continue;
|
||||
let c = 0; const o = yy * W; for (let x = xL; x < xR; x++) c += ink[o + x];
|
||||
vprof[bi] += c;
|
||||
}
|
||||
// horizontal run lengths over the whole line body (x band to cap line), vertical run lengths over all columns
|
||||
for (let yy = Math.max(ln.y0, Math.round(base - cap)); yy < base; yy++) {
|
||||
const o = yy * W; let x = xL;
|
||||
while (x < xR) { if (ink[o + x]) { const x0 = x; while (x < xR && ink[o + x]) x++; hruns.push(hLen(o, x0, x) / cap); } else x++; }
|
||||
}
|
||||
for (let x = xL; x < xR; x++) {
|
||||
let yy = ln.y0;
|
||||
while (yy < ln.y1) { if (ink[yy * W + x]) { const y0 = yy; while (yy < ln.y1 && ink[yy * W + x]) yy++; vruns.push(vLen(x, y0, yy) / cap); } else yy++; }
|
||||
}
|
||||
const gl = segment(bin, ln, m);
|
||||
const G = gl.filter((g) => g.w >= cap * 0.12 && (base - g.top) >= cap * 0.3);
|
||||
glyphN += G.length;
|
||||
const onBase = G.filter((g) => Math.abs(g.bot - base) <= tol * 1.5);
|
||||
const capG = onBase.filter((g) => base - g.top >= cap * 0.88);
|
||||
const xs = xh ? onBase.filter((g) => Math.abs(base - g.top - xh) <= Math.max(tol * 1.5, cap * 0.05)) : [];
|
||||
for (const g of capG) { advTall.push(g.w / cap); densTall.push(g.area / (g.w * g.h)); }
|
||||
for (const g of xs) { densX.push(g.area / (g.w * g.h)); advX.push(g.w / cap); }
|
||||
for (const g of onBase) { advAll.push(g.w / cap); widths.push(g.w / cap); round.push(g.w / (base - g.top) > 0.9 ? 1 : 0); }
|
||||
for (let i = 0; i + 1 < G.length; i++) { const gap = G[i + 1].x0 - G[i].x1; if (gap >= 0 && gap < cap * 0.6) gaps.push(gap / cap); }
|
||||
const xTop = base - (xh || cap * 0.55);
|
||||
const bandTop = Math.round(xTop + (base - xTop) * 0.2), bandBot = Math.round(base - (base - xTop) * 0.2);
|
||||
let runCount = 0, runRows = 0;
|
||||
for (let yy = bandTop; yy < bandBot; yy++) {
|
||||
const o = yy * W; let x = xL; runRows++;
|
||||
while (x < xR) { if (ink[o + x]) { const x0 = x; while (x < xR && ink[o + x]) x++; const L = hLen(o, x0, x); runCount++; if (L < cap * 0.5) stems.push(L / cap); } else x++; }
|
||||
}
|
||||
if (runRows) per.runDensity.push((runCount / runRows) / ((xR - xL) / cap));
|
||||
for (let x = xL; x < xR; x++) {
|
||||
let yy = ln.y0;
|
||||
while (yy < ln.y1) { if (ink[yy * W + x]) { const y0 = yy; while (yy < ln.y1 && ink[yy * W + x]) yy++; const L = vLen(x, y0, yy); if (L < cap * 0.35) thins.push(L / cap); } else yy++; }
|
||||
}
|
||||
// serif: stems that run straight to the baseline; foot width vs mid-stem width
|
||||
const runAt = (yy, x) => { const o = yy * W; if (!ink[o + x]) return 0; let a = x, b = x; while (a > xL && ink[o + a - 1]) a--; while (b + 1 < xR && ink[o + b + 1]) b++; return hLen(o, a, b + 1); };
|
||||
const yMid = Math.round(base - cap * 0.4), yHi = Math.round(base - cap * 0.18), yFoot = base - Math.max(1, Math.round(cap * 0.04));
|
||||
let x = xL;
|
||||
while (x < xR) {
|
||||
let yy = base - 1; if (!ink[yy * W + x]) { x++; continue; }
|
||||
while (yy > ln.y0 && ink[(yy - 1) * W + x]) yy--;
|
||||
if (yy > yMid) { x++; continue; }
|
||||
const x0 = x; x++; while (x < xR && ink[(base - 1) * W + x] && ink[yMid * W + x]) x++;
|
||||
const xc = Math.round((x0 + x - 1) / 2);
|
||||
const wMid = runAt(yMid, xc), wHi = runAt(yHi, xc), wFoot = runAt(yFoot, xc);
|
||||
if (wMid > 0 && wMid < cap * 0.5 && wHi <= wMid * 1.3 && wHi >= wMid * 0.7) serifR.push(wFoot / wMid);
|
||||
}
|
||||
}
|
||||
if (!capN) return null;
|
||||
const stemW = med(stems), thinW = med(thins);
|
||||
const advM = med(advAll);
|
||||
const advSd = advAll.length > 3 ? Math.sqrt(advAll.reduce((s, v) => s + (v - advM) ** 2, 0) / advAll.length) : null;
|
||||
const vsum = vprof.reduce((s, x) => s + x, 0) || 1;
|
||||
const extra = {};
|
||||
for (let i = 0; i < VBINS; i++) extra[`vprof${i}`] = vprof[i] / vsum;
|
||||
for (const q of HQ) { extra[`hrun${Math.round(q * 100)}`] = pct(hruns, q); extra[`vrun${Math.round(q * 100)}`] = pct(vruns, q); }
|
||||
extra.colq25 = pct(colHs, 0.25); extra.colq75 = pct(colHs, 0.75);
|
||||
extra.wq25 = pct(widths, 0.25); extra.wq75 = pct(widths, 0.75);
|
||||
return {
|
||||
...extra,
|
||||
capHeightPx: capSum / capN,
|
||||
glyphs: glyphN,
|
||||
advance: advM,
|
||||
advTall: advTall.length ? med(advTall) : null,
|
||||
advX: advX.length ? med(advX) : null,
|
||||
advCV: advSd != null && advM ? advSd / advM : null,
|
||||
gap: gaps.length ? med(gaps) : 0,
|
||||
xRatio: per.xh.length ? med(per.xh) : null,
|
||||
descRatio: per.desc.length ? med(per.desc) : null,
|
||||
allCaps: allCapsLines * 2 > capN,
|
||||
runDensity: med(per.runDensity),
|
||||
stemW,
|
||||
contrast: stemW && thinW ? stemW / thinW : null,
|
||||
serif: serifR.length >= 3 ? med(serifR) : null,
|
||||
roundFrac: round.length ? mean(round) : null,
|
||||
densTall: densTall.length ? med(densTall) : null,
|
||||
densX: densX.length ? med(densX) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* fingerprint(img) -> features, or null when no lettering is found. Upsamples (bilinear) when the
|
||||
* cap height is under 24px so runs and edges are measured on finer pixels.
|
||||
*/
|
||||
/**
|
||||
* Keep the dominant lettering in a region crop: the lines whose cap height is
|
||||
* within `tol` of the tallest, clipped horizontally to their own ink. A comp
|
||||
* region drawn on a 10x10 grid over-covers: the headline crop carries the
|
||||
* first line of body copy below it and a slice of the neighbouring column,
|
||||
* and every one of those small letters pulls stem width, run lengths and the
|
||||
* x-height vote toward a lighter, wider face. Returns { lines, x0, x1 } in
|
||||
* the binarized image, or null when nothing survives.
|
||||
*/
|
||||
export function isolateDominant(bin, lines, { tol = 0.28 } = {}) {
|
||||
const ms = lines.map((ln) => ({ ln, m: lineMetrics(bin, ln) })).filter((x) => x.m);
|
||||
if (!ms.length) return null;
|
||||
// The dominant class is the one holding most of the ink, not the tallest
|
||||
// line: a body-copy crop that clips the last line of the headline above it
|
||||
// is body copy. Cluster caps within tol of each other and pick the cluster
|
||||
// with the most ink mass; the tallest wins only a tie.
|
||||
const clusters = [];
|
||||
for (const x of [...ms].sort((a, b) => b.m.cap - a.m.cap)) {
|
||||
const c = clusters.find((cl) => Math.abs(cl.cap - x.m.cap) <= cl.cap * tol);
|
||||
if (c) { c.items.push(x); c.mass += x.ln.mass || 0; } else clusters.push({ cap: x.m.cap, items: [x], mass: x.ln.mass || 0 });
|
||||
}
|
||||
// Mass per line-height, so one heavy display line does not outvote five
|
||||
// lines of body copy; and a cluster of a single clipped line never wins
|
||||
// over a cluster of three or more.
|
||||
for (const c of clusters) { c.rows = c.items.reduce((n, x) => n + (x.ln.y1 - x.ln.y0), 0); c.density = c.mass / Math.max(1, c.rows); c.n = c.items.length; }
|
||||
clusters.sort((a, b) => {
|
||||
const aMulti = a.n >= 3, bMulti = b.n >= 3;
|
||||
if (aMulti !== bMulti) return aMulti ? -1 : 1;
|
||||
return (b.mass - a.mass) || (b.cap - a.cap);
|
||||
});
|
||||
const keep = clusters[0].items;
|
||||
const capMax = Math.max(...keep.map((x) => x.m.cap));
|
||||
// horizontal extent of the kept lines' tallest ink columns only: a small
|
||||
// column of body text beside the headline shares its rows but not its height
|
||||
const { W, ink } = bin;
|
||||
let x0 = W, x1 = 0;
|
||||
for (const { ln, m } of keep) {
|
||||
const top = Math.round(m.base - m.cap * 0.75);
|
||||
for (let x = m.xL; x < m.xR; x++) {
|
||||
let tall = false;
|
||||
for (let y = top; y < m.base && !tall; y++) if (ink[y * W + x]) tall = true;
|
||||
if (!tall) continue;
|
||||
// a column is headline ink when a run of at least 0.5 cap of ink stands in it
|
||||
let run = 0, best = 0;
|
||||
for (let y = ln.y0; y < ln.y1; y++) { if (ink[y * W + x]) { run++; if (run > best) best = run; } else run = 0; }
|
||||
if (best >= m.cap * 0.5) { if (x < x0) x0 = x; if (x + 1 > x1) x1 = x + 1; }
|
||||
}
|
||||
}
|
||||
if (x1 <= x0) return null;
|
||||
// grow the box by half a cap so glyph sides and the tracking gap survive
|
||||
const pad = Math.round(capMax * 0.5);
|
||||
return { lines: keep.map((x) => x.ln), x0: Math.max(0, x0 - pad), x1: Math.min(W, x1 + pad), dropped: ms.length - keep.length };
|
||||
}
|
||||
|
||||
function maskOutside(bin, x0, x1, lines) {
|
||||
const { W, H, ink, covA } = bin;
|
||||
const keepRow = new Uint8Array(H);
|
||||
for (const ln of lines) for (let y = ln.y0; y < ln.y1; y++) keepRow[y] = 1;
|
||||
const ink2 = new Uint8Array(ink.length), cov2 = new Float32Array(covA.length);
|
||||
for (let y = 0; y < H; y++) {
|
||||
if (!keepRow[y]) continue;
|
||||
for (let x = x0; x < x1; x++) { const i = y * W + x; ink2[i] = ink[i]; cov2[i] = covA[i]; }
|
||||
}
|
||||
return { ...bin, ink: ink2, covA: cov2, cov: (i) => cov2[i] };
|
||||
}
|
||||
|
||||
export function fingerprint(img, { minCap = 24, minGlyphs = 3, isolate = true } = {}) {
|
||||
let bin = binarize(img);
|
||||
let { lines } = findLines(bin);
|
||||
if (!lines.length) return null;
|
||||
let isolated = 0;
|
||||
if (isolate && lines.length > 1) {
|
||||
const iso = isolateDominant(bin, lines);
|
||||
if (iso && (iso.dropped > 0 || iso.x1 - iso.x0 < bin.W * 0.9)) {
|
||||
bin = maskOutside(bin, iso.x0, iso.x1, iso.lines);
|
||||
lines = iso.lines;
|
||||
isolated = iso.dropped;
|
||||
}
|
||||
}
|
||||
let f = measure(bin, lines);
|
||||
// fewer than minGlyphs separable glyphs is not lettering (a rule, a solid
|
||||
// bar, one letterform): callers read null as "no separable lettering"
|
||||
if (!f || f.glyphs < minGlyphs) return null;
|
||||
let scale = 1;
|
||||
if (f.capHeightPx < minCap && f.capHeightPx >= 4) {
|
||||
scale = Math.min(4, Math.ceil(minCap / f.capHeightPx));
|
||||
const up = resize(img, img.width * scale, img.height * scale);
|
||||
bin = binarize(up);
|
||||
lines = findLines(bin).lines;
|
||||
// the upsample re-reads the whole crop: isolate again so the clipped
|
||||
// headline or the drawing does not come back at scale
|
||||
if (isolate && lines.length > 1) {
|
||||
const iso2 = isolateDominant(bin, lines);
|
||||
if (iso2 && (iso2.dropped > 0 || iso2.x1 - iso2.x0 < bin.W * 0.9)) { bin = maskOutside(bin, iso2.x0, iso2.x1, iso2.lines); lines = iso2.lines; isolated = Math.max(isolated, iso2.dropped); }
|
||||
}
|
||||
const f2 = lines.length ? measure(bin, lines) : null;
|
||||
if (f2) f = f2;
|
||||
else scale = 1;
|
||||
}
|
||||
const r = { lines: lines.length, glyphs: f.glyphs, capHeightPx: +(f.capHeightPx / scale).toFixed(1), inkIsDark: bin.inkIsDark, upsampled: scale > 1, allCaps: f.allCaps, isolatedFrom: isolated, weight: f.densTall == null && f.densX == null ? null : +(f.densTall ?? f.densX).toFixed(4) };
|
||||
for (const k of FEATURES) r[k] = f[k] == null ? null : +f[k].toFixed(4);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance normalization fitted on 299 held-out probes (150 at ~30px cap, 149
|
||||
* at ~14px, text different from the index text) against a 3,092-entry Google
|
||||
* Fonts index: std = within-family noise (1.4826 x median |probe - own index
|
||||
* entry|, floored at 5% of the catalog IQR spread), w = group weight from
|
||||
* coordinate descent on top-5 family recall. mean is unused by the distance.
|
||||
*/
|
||||
export const STATS = {
|
||||
advance: { std: 0.07648, w: 0 },
|
||||
advTall: { std: 0.25331, w: 0 },
|
||||
advX: { std: 0.05144, w: 1.5 },
|
||||
advCV: { std: 0.0857, w: 1 },
|
||||
gap: { std: 0.02668, w: 1 },
|
||||
xRatio: { std: 0.02315, w: 1 },
|
||||
descRatio: { std: 0.17831, w: 1 },
|
||||
stemW: { std: 0.01922, w: 1 },
|
||||
contrast: { std: 0.05969, w: 3 },
|
||||
serif: { std: 0.31477, w: 0.5 },
|
||||
roundFrac: { std: 0.09341, w: 1 },
|
||||
densTall: { std: 0.05708, w: 2 },
|
||||
densX: { std: 0.07666, w: 0 },
|
||||
runDensity: { std: 0.18199, w: 1 },
|
||||
vprof0: { std: 0.01178, w: 1 },
|
||||
vprof1: { std: 0.01331, w: 1 },
|
||||
vprof2: { std: 0.02745, w: 1 },
|
||||
vprof3: { std: 0.03046, w: 1 },
|
||||
vprof4: { std: 0.01933, w: 1 },
|
||||
vprof5: { std: 0.01737, w: 1 },
|
||||
vprof6: { std: 0.0336, w: 1 },
|
||||
vprof7: { std: 0.03195, w: 1 },
|
||||
vprof8: { std: 0.03271, w: 1 },
|
||||
vprof9: { std: 0.02951, w: 1 },
|
||||
hrun25: { std: 0.01751, w: 1 },
|
||||
hrun50: { std: 0.02124, w: 1 },
|
||||
hrun75: { std: 0.04503, w: 1 },
|
||||
hrun90: { std: 0.06844, w: 1 },
|
||||
vrun25: { std: 0.01895, w: 1 },
|
||||
vrun50: { std: 0.02405, w: 1 },
|
||||
vrun75: { std: 0.06199, w: 1 },
|
||||
vrun90: { std: 0.09486, w: 1 },
|
||||
colq25: { std: 0.02906, w: 1 },
|
||||
colq75: { std: 0.18204, w: 1 },
|
||||
wq25: { std: 0.19862, w: 1 },
|
||||
wq75: { std: 0.09687, w: 1 },
|
||||
};
|
||||
export const Z_CLIP = 3;
|
||||
|
||||
/** Weighted L1 over z-scored features; a feature missing on either side is skipped and the weight mass renormalized. */
|
||||
/**
|
||||
* The two readings a designer makes before any detail: how wide, how heavy.
|
||||
* Width from the advance of tall glyphs (all-caps crops) or x-height glyphs;
|
||||
* weight from ink density of tall glyphs. Both are on the same scale in the
|
||||
* comp crop and in a catalog render, so their gap is a plain ratio. Distance
|
||||
* adds a penalty that grows with the ratio's log: a face 50% wider or 35%
|
||||
* lighter than the comp cannot rank above one that is right on both, whatever
|
||||
* its run-length profile says. Weighted like three fine features (the width
|
||||
* gap and the weight gap each score up to zClip x 1.5).
|
||||
*/
|
||||
export function grossGap(a, b) {
|
||||
const pick = (f, keys) => { for (const k of keys) if (f[k] != null) return { k, v: f[k] }; return null; };
|
||||
const wa = pick(a, ['advX', 'advTall', 'advance']), wb = wa ? (b[wa.k] != null ? { k: wa.k, v: b[wa.k] } : null) : null;
|
||||
const ha = pick(a, ['densTall', 'densX', 'stemW']), hb = ha ? (b[ha.k] != null ? { k: ha.k, v: b[ha.k] } : null) : null;
|
||||
const gap = (x, y) => (x && y && x.v > 0 && y.v > 0 ? Math.abs(Math.log(y.v / x.v)) : null);
|
||||
return { width: gap(wa, wb), weight: gap(ha, hb) };
|
||||
}
|
||||
|
||||
export const GROSS_STD = { width: 0.12, weight: 0.12 }; // one "step" of width class or weight class, in log ratio
|
||||
export const GROSS_W = 1.5;
|
||||
|
||||
export function distance(a, b, stats = STATS, { p = 1, zClip = Z_CLIP, gross = true } = {}) {
|
||||
let d = 0, wsum = 0;
|
||||
if (gross) {
|
||||
const g = grossGap(a, b);
|
||||
for (const k of ['width', 'weight']) {
|
||||
if (g[k] == null) continue;
|
||||
const z = Math.min(zClip, g[k] / GROSS_STD[k]);
|
||||
d += GROSS_W * (p === 1 ? z : z * z); wsum += GROSS_W;
|
||||
}
|
||||
}
|
||||
for (const k of FEATURES) {
|
||||
const s = stats[k]; if (!s || !s.w) continue;
|
||||
const av = a[k], bv = b[k];
|
||||
if (av == null || bv == null) continue;
|
||||
const z = Math.min(zClip, Math.abs(av - bv) / s.std);
|
||||
d += s.w * (p === 1 ? z : z * z); wsum += s.w;
|
||||
}
|
||||
if (!wsum) return Infinity;
|
||||
const v = d / wsum;
|
||||
return p === 1 ? v : Math.sqrt(v);
|
||||
}
|
||||
|
||||
/** Debug: per-line metrics (base, R, xh, mode counts) for a raster. */
|
||||
export function _debugLines(img) {
|
||||
const bin = binarize(img);
|
||||
const { lines } = findLines(bin);
|
||||
return lines.map((ln) => { const m = lineMetrics(bin, ln); if (!m) return { ln, m: null }; const hs = m.hs.map((h) => +(h / m.R).toFixed(2)).sort((a, b) => a - b); const hist = {}; for (const h of hs) { const b = Math.round(h * 20) / 20; hist[b] = (hist[b] || 0) + 1; } return { y0: ln.y0, y1: ln.y1, base: m.base, R: +m.R.toFixed(1), xh: m.xh && +m.xh.toFixed(1), hist }; });
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* font-index: the fingerprint index of the Google Fonts catalog that
|
||||
* font-match.mjs --rank uses as its candidate generator, and the pack/unpack
|
||||
* helpers the release-time build (repo scripts/build-font-index.mjs) shares with it.
|
||||
*
|
||||
* File: skill/scripts/data/font-index.json
|
||||
* {
|
||||
* schema: 1,
|
||||
* text: "<index text every face was rendered with>",
|
||||
* sizes: [48, 14], // cap heights (px) the catalog was rendered at
|
||||
* features: [...], // feature names, in vector order (the fitted-nonzero subset of FEATURES)
|
||||
* categories: ["sans", ...], // category index -> name
|
||||
* entries: [[family, weight, categoryIdx, variable(0|1), vec48, vec14], ...]
|
||||
* }
|
||||
* A vector is a string of 3-char base-36 numbers, one per feature, each the
|
||||
* feature value x 1000 (three decimals, clipped at 46.655); "___" is null.
|
||||
* That packing keeps ~3,000 faces x 2 sizes x 33 features under 750 KB on
|
||||
* disk, which is what makes it shippable inside the skill without gzip.
|
||||
*
|
||||
* Two sizes because the fingerprint's features are stable within a factor of
|
||||
* ~2 in size but not from 48px down to 14px: a crop is routed to the index
|
||||
* rendered nearer its own cap height (ROUTE_CAP_PX is the boundary).
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FEATURES, STATS, distance } from './font-fingerprint.mjs';
|
||||
|
||||
export const INDEX_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'data', 'font-index.json');
|
||||
/** Cap heights the catalog is rendered at. `48c` is the same 48px cap in ALL
|
||||
* CAPS text (schema 2), queried for caps crops; a schema-1 index without it
|
||||
* routes caps crops to the mixed-case 48 as before. */
|
||||
export const INDEX_SIZES = [48, 14, '48c'];
|
||||
/** Crops with a cap height under this many px query the 14px index. */
|
||||
export const ROUTE_CAP_PX = 22;
|
||||
/** Below this cap height the fingerprint is not trustworthy; callers size by the box instead. */
|
||||
export const MIN_RANK_CAP_PX = 10;
|
||||
export const CATEGORIES = ['sans', 'serif', 'display', 'handwriting', 'mono'];
|
||||
/** The features the index stores: the ones the fitted distance gives nonzero weight. */
|
||||
export const GROSS_FEATURES = ['advance', 'advTall', 'advX', 'densTall', 'densX', 'stemW'];
|
||||
export const INDEX_FEATURES = FEATURES.filter((k) => (STATS[k] && STATS[k].w > 0) || GROSS_FEATURES.includes(k));
|
||||
|
||||
const NULL_TOKEN = '___';
|
||||
const MAX_Q = 36 ** 3 - 1;
|
||||
|
||||
export function packVector(fp, features = INDEX_FEATURES) {
|
||||
return features.map((k) => {
|
||||
const v = fp?.[k];
|
||||
if (v == null || !Number.isFinite(v)) return NULL_TOKEN;
|
||||
return Math.min(MAX_Q, Math.max(0, Math.round(v * 1000))).toString(36).padStart(3, '0');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function unpackVector(s, features = INDEX_FEATURES) {
|
||||
const out = {};
|
||||
for (let i = 0; i < features.length; i++) {
|
||||
const t = s.slice(i * 3, i * 3 + 3);
|
||||
out[features[i]] = t === NULL_TOKEN || t.length < 3 ? null : parseInt(t, 36) / 1000;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let cached = null;
|
||||
/**
|
||||
* Load and decode the index. Returns null when the file is missing (callers
|
||||
* fall back to their built-in shortlist). Result: { schema, text, sizes,
|
||||
* features, entries: [{ family, weight, category, variable, fp: { 48: {...}, 14: {...}|null } }] }.
|
||||
*/
|
||||
export function loadFontIndex(file = INDEX_PATH) {
|
||||
if (cached && cached.file === file) return cached.index;
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const features = raw.features || INDEX_FEATURES;
|
||||
const cats = raw.categories || CATEGORIES;
|
||||
const sizes = raw.sizes || INDEX_SIZES;
|
||||
const entries = raw.entries.map((e) => {
|
||||
const fp = {};
|
||||
sizes.forEach((sz, i) => { const v = e[4 + i]; fp[sz] = v ? unpackVector(v, features) : null; });
|
||||
return { family: e[0], weight: e[1], category: cats[e[2]] ?? String(e[2]), variable: !!e[3], fp };
|
||||
});
|
||||
const index = { schema: raw.schema, text: raw.text, sizes, features, entries };
|
||||
cached = { file, index };
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Which of the index's cap sizes a crop with this cap height should query. */
|
||||
export function routeSize(capHeightPx, sizes = INDEX_SIZES, { allCaps = false } = {}) {
|
||||
const numeric = sizes.filter((s) => typeof s === 'number').sort((a, b) => a - b);
|
||||
if (allCaps && capHeightPx >= ROUTE_CAP_PX && sizes.includes('48c')) return '48c';
|
||||
return capHeightPx < ROUTE_CAP_PX ? numeric[0] : numeric[numeric.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* The n nearest catalog faces to a comp fingerprint, routed by cap height.
|
||||
* Returns [{ family, weight, category, variable, d, size }] sorted by distance;
|
||||
* at most `perFamily` entries of one family so the shortlist spans faces, not weights.
|
||||
*/
|
||||
/**
|
||||
* Faces that are not lettering: barcodes, redaction bars, placeholder "flow"
|
||||
* text, dingbats, symbol fonts, and effect faces (outlines, shades, glitch,
|
||||
* pixel, 3D) whose fingerprint lands near heavy condensed text without being
|
||||
* usable as it. A comp headline never wants them; a caller who does can pass
|
||||
* them by name in `--candidates`.
|
||||
*/
|
||||
export const NON_TEXT_FAMILY = /barcode|^redacted|^flow (block|circular|rounded)|dings|symbols|^bungee (hairline|outline|shade|spice)|^rubik (80s|beastly|broken|bubbles|burned|dirt|distressed|doodle|gemstones|glitch|iso|lines|marker|maze|microbe|moonrocks|pixels|puddles|scribble|spray|storm|vinyl|wet)|^(nabla|honk|kablammo|sixtyfour|workbench|codystar|rock 3d|zen dots|ballet|butcherman|creepster|eater|faster one|frijole|nosifer|metal mania|miltonian)/i;
|
||||
|
||||
export function candidatesFromIndex(fp, index, { n = 25, category = null, perFamily = 2, includeNonText = false } = {}) {
|
||||
if (!fp || !index) return [];
|
||||
const size = routeSize(fp.capHeightPx, index.sizes, { allCaps: !!fp.allCaps });
|
||||
const wantCat = category ? String(category).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean) : null;
|
||||
const scored = [];
|
||||
for (const e of index.entries) {
|
||||
if (wantCat && !wantCat.includes(e.category)) continue;
|
||||
if (!includeNonText && NON_TEXT_FAMILY.test(e.family)) continue;
|
||||
const v = e.fp[size];
|
||||
if (!v) continue;
|
||||
const d = distance(fp, v);
|
||||
if (!Number.isFinite(d)) continue;
|
||||
scored.push({ family: e.family, weight: e.weight, category: e.category, variable: e.variable, d, size });
|
||||
}
|
||||
scored.sort((a, b) => a.d - b.d);
|
||||
const perFam = new Map(); const out = [];
|
||||
for (const s of scored) {
|
||||
const c = perFam.get(s.family) || 0;
|
||||
if (c >= perFamily) continue;
|
||||
perFam.set(s.family, c + 1); out.push(s);
|
||||
if (out.length >= n) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* Hero-gate checks that name a miss as a number the model can act on.
|
||||
*
|
||||
* comp-diff scores regions; these read what a designer reads when the two
|
||||
* frames sit side by side and says it as numbers: the headline is set at
|
||||
* cap 78px where the comp's is 103; it wraps to four lines where the comp
|
||||
* has three; its ink is #2a2a2a where the comp's is #a72f1b; it starts
|
||||
* 60px lower in its box; the masthead is 92px tall where the comp's is 58;
|
||||
* these grid cells carry ink the comp does not have (a kicker, a divider, a
|
||||
* second nav row). Every one of those was a pin in the first human review
|
||||
* of the third sweep, on builds the region scores had already let through.
|
||||
*
|
||||
* All functions are pure over decoded rasters and the spec; the gate wires
|
||||
* them and decides what vetoes.
|
||||
*/
|
||||
import { fingerprint } from './font-fingerprint.mjs';
|
||||
import { crop } from './raster.mjs';
|
||||
import { dominantColors, deltaE, detailGrid } from './image-metrics.mjs';
|
||||
import { inkBox } from '../comp-diff.mjs';
|
||||
|
||||
/** Dominant ink colour of a crop: the heaviest cluster that is not the ground. */
|
||||
export function inkColor(img) {
|
||||
const cols = dominantColors(img, 4);
|
||||
if (!cols.length) return null;
|
||||
const ground = cols[0];
|
||||
const ink = cols.find((c) => c !== ground && deltaE(c.lab, ground.lab) > 20) || null;
|
||||
return { ground, ink };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare one text region's build crop against the comp's measurement.
|
||||
* `region` is a spec region with `type.comp` (font-match --measure) and
|
||||
* `px`; `compCrop` / `buildCrop` are the region crops at comp scale.
|
||||
* Returns { findings: string[], metrics }.
|
||||
*/
|
||||
export function textRegionCheck(region, compCrop, buildCrop, { capTol = 0.22, minCap = 10 } = {}) {
|
||||
const findings = [];
|
||||
// Measure the comp crop now rather than trusting spec.type.comp: the spec
|
||||
// may carry an older fingerprint's reading, and this check has to agree
|
||||
// with itself on both sides.
|
||||
const comp = fingerprint(compCrop);
|
||||
// colour reads on any text region, measured or not: a spine set vertical
|
||||
// (unmeasurable) came back white on red where the comp had black on red
|
||||
// in five builds
|
||||
const colourOnly = () => {
|
||||
const ca = inkColor(compCrop), cb = inkColor(buildCrop);
|
||||
if (ca && cb && ca.ink && cb.ink && deltaE(ca.ink.lab, cb.ink.lab) > 22) findings.push(`text ${region.id}: ink is ${cb.ink.hex} in the build, ${ca.ink.hex} in the comp; use the comp's colour`);
|
||||
return { findings, metrics: null };
|
||||
};
|
||||
if (!comp || !comp.capHeightPx || comp.capHeightPx < minCap || comp.glyphs < 6) return colourOnly();
|
||||
// rotated type (a spine set vertical) reads as many short 'lines' of one
|
||||
// or two glyphs; the fingerprint has nothing to say about it
|
||||
if (comp.lines >= 5 && comp.glyphs / comp.lines < 3) return colourOnly();
|
||||
// a cap taller than half the box is a drawing read as a glyph, not type
|
||||
if (comp.capHeightPx > compCrop.height * 0.6) return colourOnly();
|
||||
const bfp = fingerprint(buildCrop);
|
||||
const metrics = { comp: { cap: comp.capHeightPx, lines: comp.lines, glyphs: comp.glyphs }, build: bfp ? { cap: bfp.capHeightPx, lines: bfp.lines, glyphs: bfp.glyphs } : null };
|
||||
if (!bfp || bfp.glyphs < 4) {
|
||||
// nothing legible in the box: comp-diff's missing/contradicted covers it
|
||||
return { findings, metrics };
|
||||
}
|
||||
const capDelta = (bfp.capHeightPx - comp.capHeightPx) / comp.capHeightPx;
|
||||
if (Math.abs(capDelta) > capTol) {
|
||||
findings.push(`text ${region.id}: cap height ${bfp.capHeightPx}px in the build, ${comp.capHeightPx}px in the comp (${capDelta > 0 ? '+' : ''}${Math.round(capDelta * 100)}%); set font-size so the cap height renders at ${comp.capHeightPx}px${region.type.chosen ? ` (font-match ranked ${region.type.chosen.family} ${region.type.chosen.weight} at ${region.type.chosen.fontSizePx}px)` : ''}`);
|
||||
}
|
||||
if (comp.lines >= 2 && bfp.lines !== comp.lines && Math.abs(bfp.lines - comp.lines) >= 1) {
|
||||
findings.push(`text ${region.id}: ${bfp.lines} line${bfp.lines === 1 ? '' : 's'} in the build, ${comp.lines} in the comp; the measure (max-width, font-size, letter-spacing) wraps it differently, so the block is a different shape`);
|
||||
} else if (comp.lines >= 3 && bfp.lines === comp.lines && Math.abs(capDelta) <= capTol) {
|
||||
// same lines at the same size: the leading is the remaining shape
|
||||
const ba0 = inkBox(compCrop), bb0 = inkBox(buildCrop);
|
||||
if (ba0 && bb0) {
|
||||
const pa = ba0.h / comp.lines, pb = bb0.h / bfp.lines;
|
||||
const dp = (pb - pa) / pa;
|
||||
if (Math.abs(dp) > 0.2) findings.push(`text ${region.id}: line pitch ${Math.round(pb)}px in the build, ${Math.round(pa)}px in the comp (${dp > 0 ? '+' : ''}${Math.round(dp * 100)}%); set line-height so ${comp.lines} lines stand ${Math.round(ba0.h)}px tall`);
|
||||
}
|
||||
}
|
||||
// tracking: the gap between glyphs in cap units, when both sides read it
|
||||
if (comp.gap != null && bfp.gap != null && Math.abs(capDelta) <= capTol && comp.glyphs >= 8 && bfp.glyphs >= 8) {
|
||||
const dg = bfp.gap - comp.gap;
|
||||
if (Math.abs(dg) > Math.max(0.03, comp.gap * 0.5)) findings.push(`text ${region.id}: letter-spacing is ${dg > 0 ? 'wider' : 'tighter'} than the comp's (gap ${bfp.gap.toFixed(3)} vs ${comp.gap.toFixed(3)} of the cap height); set letter-spacing to ${dg > 0 ? 'close' : 'open'} it by about ${Math.abs(Math.round(dg * comp.capHeightPx))}px`);
|
||||
}
|
||||
// weight: compare ink density of tall glyphs when both sides have it and
|
||||
// the sizes agree (density at a different cap is a different reading)
|
||||
if (comp.densTall != null && bfp.densTall != null && Math.abs(capDelta) <= capTol) {
|
||||
const r = bfp.densTall / comp.densTall;
|
||||
if (r > 1.25) findings.push(`text ${region.id}: the face renders ${Math.round((r - 1) * 100)}% heavier than the comp's (ink density ${bfp.densTall.toFixed(2)} vs ${comp.densTall.toFixed(2)}); drop a weight step or use the ranked face`);
|
||||
else if (r < 0.75) findings.push(`text ${region.id}: the face renders ${Math.round((1 - r) * 100)}% lighter than the comp's (ink density ${bfp.densTall.toFixed(2)} vs ${comp.densTall.toFixed(2)}); raise a weight step or use the ranked face`);
|
||||
}
|
||||
// colour: dominant ink of each crop. Small type on a ruled or grainy
|
||||
// ground (a track row across staff lines at cap 14) has no reliable ink
|
||||
// cluster; the reading fired both ways on neighbouring rows of one list.
|
||||
if (comp.capHeightPx >= 16) {
|
||||
const ca = inkColor(compCrop), cb = inkColor(buildCrop);
|
||||
if (ca && cb && ca.ink && cb.ink) {
|
||||
const d = deltaE(ca.ink.lab, cb.ink.lab);
|
||||
if (d > 22) findings.push(`text ${region.id}: ink is ${cb.ink.hex} in the build, ${ca.ink.hex} in the comp; use the comp's colour`);
|
||||
}
|
||||
}
|
||||
// vertical placement inside the box: top of ink
|
||||
const ba = inkBox(compCrop), bb = inkBox(buildCrop);
|
||||
if (ba && bb) {
|
||||
const dy = bb.y - ba.y;
|
||||
if (Math.abs(dy) > Math.max(12, compCrop.height * 0.15)) findings.push(`text ${region.id}: its first line starts ${Math.abs(Math.round(dy))}px ${dy > 0 ? 'lower' : 'higher'} than in the comp (${bb.y}px vs ${ba.y}px into the region box); the spacing above it is ${dy > 0 ? 'too large' : 'too small'}`);
|
||||
const dx = bb.x - ba.x;
|
||||
if (Math.abs(dx) > Math.max(12, compCrop.width * 0.15)) findings.push(`text ${region.id}: it starts ${Math.abs(Math.round(dx))}px ${dx > 0 ? 'further right' : 'further left'} than in the comp`);
|
||||
}
|
||||
metrics.capDelta = +capDelta.toFixed(3);
|
||||
return { findings, metrics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows of a crop that carry a horizontal rule: a row whose gray step from
|
||||
* the row above (or below) is strong across at least `span` of the width.
|
||||
* Returns row indices sorted top to bottom.
|
||||
*/
|
||||
export function ruleRows(img, { span = 0.5, step = 28 } = {}) {
|
||||
const W = img.width, H = img.height;
|
||||
const gray = (x, y) => { const i = (y * W + x) * 4; return 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2]; };
|
||||
const rows = [];
|
||||
for (let y = 1; y < H - 1; y++) {
|
||||
let strong = 0;
|
||||
for (let x = 0; x < W; x++) { const d = Math.max(Math.abs(gray(x, y) - gray(x, y - 1)), Math.abs(gray(x, y) - gray(x, y + 1))); if (d > step) strong++; }
|
||||
if (strong >= W * span) rows.push(y);
|
||||
}
|
||||
// collapse adjacent rows into one edge
|
||||
const out = [];
|
||||
for (const y of rows) if (!out.length || y - out[out.length - 1] > 3) out.push(y);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A thin chrome region (masthead, nav bar, footer strip) has a height, and
|
||||
* its height is where its rule sits. Compare the first horizontal rule's row
|
||||
* in comp vs build; fall back to the ink extents when neither has a rule.
|
||||
*/
|
||||
export function chromeStripCheck(region, compCrop, buildCrop) {
|
||||
const findings = [];
|
||||
const strip = compCrop.height <= compCrop.width * 0.35;
|
||||
if (!strip) return { findings };
|
||||
// a control that is one link or one button, not a bar across its box, has
|
||||
// no strip height to compare (its underline read as a 'rule' for 27
|
||||
// attempts in one session)
|
||||
if (region.kind === 'control') {
|
||||
const ib = inkBox(compCrop);
|
||||
if (!ib || ib.w < compCrop.width * 0.6) return { findings };
|
||||
}
|
||||
const ra = ruleRows(compCrop), rb = ruleRows(buildCrop);
|
||||
if (ra.length && rb.length) {
|
||||
// the rule that closes the strip is the first one from the top (a grid
|
||||
// row often carries the next element's top edge lower down)
|
||||
const ya = ra[0], yb = rb[0];
|
||||
const dy = yb - ya;
|
||||
if (Math.abs(dy) > Math.max(5, compCrop.height * 0.06)) findings.push(`${region.kind} ${region.id}: its rule sits ${ya}px into the box in the comp and ${yb}px in the build (${dy > 0 ? '+' : ''}${dy}px), so the strip is ${dy > 0 ? 'taller' : 'shorter'} than the comp's; match the height, not only the position`);
|
||||
return { findings, comp: ya, build: yb };
|
||||
}
|
||||
const ba = inkBox(compCrop), bb = inkBox(buildCrop);
|
||||
if (!ba || !bb) return { findings };
|
||||
if (ba.w >= compCrop.width * 0.6 && ba.h <= compCrop.height * 0.6) {
|
||||
const dh = bb.h - ba.h;
|
||||
if (Math.abs(dh) > Math.max(10, ba.h * 0.25)) findings.push(`${region.kind} ${region.id}: its ink is ${bb.h}px tall in the build and ${ba.h}px in the comp (${dh > 0 ? '+' : ''}${dh}px); match the height, not only the position`);
|
||||
}
|
||||
return { findings, comp: ba, build: bb };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cells of the frame where the build carries ink and the comp is calm.
|
||||
* Returns { cells: [{col,row,label}], fraction } on a cols x rows grid.
|
||||
* `floor` is the comp energy under which a cell counts as calm; `added` is
|
||||
* the build energy over which the build counts as inked.
|
||||
*/
|
||||
export function inventedInk(comp, build, { cols = 10, rows = 10, floor = 10, added = 12, ratio = 2.5 } = {}) {
|
||||
const a = detailGrid(comp, cols, rows, 512), b = detailGrid(build, cols, rows, 512);
|
||||
const cells = [];
|
||||
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
|
||||
const i = r * cols + c;
|
||||
// calm in the comp (grain, flat ground) and inked in the build well past
|
||||
// what grain would give: a kicker over paper, a divider, a nav row
|
||||
if (!(a.cells[i] < floor && b.cells[i] > Math.max(added, a.cells[i] * ratio))) continue;
|
||||
// the comp must be calm around the cell too: a hard edge one pixel over
|
||||
// the cell boundary in the build (a bar shifted by a subpixel of the
|
||||
// alignment) reads as invented otherwise
|
||||
let neighbourhood = 0, n = 0;
|
||||
for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) { const rr = r + dr, cc = c + dc; if (rr < 0 || cc < 0 || rr >= rows || cc >= cols) continue; neighbourhood += a.cells[rr * cols + cc]; n++; }
|
||||
if (neighbourhood / n >= floor * 2) continue;
|
||||
cells.push({ col: c, row: r, label: `${String.fromCharCode(65 + c)}${r}`, comp: +a.cells[i].toFixed(1), build: +b.cells[i].toFixed(1) });
|
||||
}
|
||||
return { cells, fraction: cells.length / (cols * rows) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A plate cropped by its box: the comp's artwork keeps a margin inside the
|
||||
* region on some side and the build's ink runs flush to that edge (object-fit:
|
||||
* cover on a box smaller than the artwork's aspect, or an <img> sized to the
|
||||
* column). The best build of the fifth sweep passed the hero at 87% with the
|
||||
* cover arch cut off at the left and bottom; the human review called it a
|
||||
* bug in one word. Returns the sides clipped, or [].
|
||||
*/
|
||||
export function plateClipCheck(region, compCrop, buildCrop, { margin = 6 } = {}) {
|
||||
const a = inkBox(compCrop), b = inkBox(buildCrop);
|
||||
if (!a || !b) return { sides: [] };
|
||||
const W = compCrop.width, H = compCrop.height;
|
||||
const sides = [];
|
||||
const flush = (v) => v <= 1;
|
||||
if (a.x >= margin && flush(b.x)) sides.push('left');
|
||||
if (a.y >= margin && flush(b.y)) sides.push('top');
|
||||
if (W - (a.x + a.w) >= margin && flush(W - (b.x + b.w))) sides.push('right');
|
||||
if (H - (a.y + a.h) >= margin && flush(H - (b.y + b.h))) sides.push('bottom');
|
||||
return { sides, comp: a, build: b };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline SVG that is an illustration, not an icon. An icon is small (a
|
||||
* viewBox or box under `iconPx` on its long side) with a few paths; anything
|
||||
* with a real path budget is a drawing in code: a diagram, a rack of
|
||||
* carburetors, staff notation, leader lines with arrows, a "terrible svg
|
||||
* approximation of the asset". Those ship as plates or as part of the plate
|
||||
* they annotate. Returns one entry per offending <svg> with a snippet.
|
||||
*
|
||||
* `html` is the artifact source. `pathBudget` counts characters of path
|
||||
* data (d="..."), points, and polyline/polygon points across the element.
|
||||
*/
|
||||
export function svgIllustrations(html, { iconPx = 64, pathBudget = 480, maxPaths = 8 } = {}) {
|
||||
const out = [];
|
||||
const re = /<svg\b([^>]*)>([\s\S]*?)<\/svg>/gi;
|
||||
let m;
|
||||
while ((m = re.exec(html))) {
|
||||
const attrs = m[1], body = m[2];
|
||||
const paths = (body.match(/<path\b/gi) || []).length + (body.match(/<(polyline|polygon|line|circle|ellipse|rect)\b/gi) || []).length;
|
||||
let budget = 0;
|
||||
for (const d of body.matchAll(/\sd="([^"]*)"/g)) budget += d[1].length;
|
||||
for (const pts of body.matchAll(/\spoints="([^"]*)"/g)) budget += pts[1].length;
|
||||
const vb = /viewBox="\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)/.exec(attrs);
|
||||
const w = /\swidth="([\d.]+)(px)?"/.exec(attrs), h = /\sheight="([\d.]+)(px)?"/.exec(attrs);
|
||||
const long = Math.max(vb ? Math.max(+vb[1], +vb[2]) : 0, w ? +w[1] : 0, h ? +h[1] : 0);
|
||||
const iconSized = long > 0 && long <= iconPx && paths <= maxPaths;
|
||||
const uses = /<use\b/i.test(body) && paths === 0; // a sprite reference
|
||||
if (uses) continue;
|
||||
if (iconSized && budget <= pathBudget) continue;
|
||||
if (budget <= pathBudget && paths <= maxPaths && long === 0 && !/<(text|image)\b/i.test(body)) continue; // a tiny inline glyph with no size hint
|
||||
if (budget > pathBudget || paths > maxPaths || (long > iconPx && paths > 0)) {
|
||||
const id = /\b(id|class|aria-label|data-region)="([^"]+)"/i.exec(attrs);
|
||||
out.push({ snippet: `<svg${attrs.slice(0, 80).replace(/\s+/g, ' ')}...> (${paths} shapes, ${budget} chars of path data${long ? `, ${long}px` : ''})`, label: id ? id[2] : null, paths, budget, long });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
/**
|
||||
* Perceptual measures for comparing a comp with a build screenshot. Pure
|
||||
* functions over `{ width, height, data }` RGBA images; no I/O.
|
||||
*
|
||||
* Three families, because a build fails a comp in three separable ways:
|
||||
*
|
||||
* - structure: is the composition the same? Measured as SSIM over a blurred
|
||||
* grayscale downsample, which forgives font hinting and a few pixels of
|
||||
* drift and punishes a moved, missing, or invented region.
|
||||
* - color: is the palette and its distribution the same? Histogram
|
||||
* intersection in a coarse quantized space plus a dominant-color extraction,
|
||||
* so a navy page built from a bone comp fails even if the shapes match.
|
||||
* - detail: is the material there? Local high-frequency energy per cell. A
|
||||
* comp with an illustration, texture, or photograph carries energy a flat
|
||||
* CSS stand-in does not; the ratio build/comp per region is the most direct
|
||||
* measure of "the plate got replaced by a gradient".
|
||||
*/
|
||||
import { resize } from './raster.mjs';
|
||||
|
||||
export function toGray(img) {
|
||||
const g = new Float32Array(img.width * img.height);
|
||||
for (let i = 0, p = 0; i < g.length; i++, p += 4) {
|
||||
const a = img.data[p + 3] / 255;
|
||||
// composite over white so transparent regions read as the page ground
|
||||
const r = img.data[p] * a + 255 * (1 - a), gg = img.data[p + 1] * a + 255 * (1 - a), b = img.data[p + 2] * a + 255 * (1 - a);
|
||||
g[i] = 0.2126 * r + 0.7152 * gg + 0.0722 * b;
|
||||
}
|
||||
return { width: img.width, height: img.height, data: g };
|
||||
}
|
||||
|
||||
/** Separable box blur on a float gray image, radius r. */
|
||||
export function blurGray(gray, r) {
|
||||
if (r <= 0) return gray;
|
||||
const { width, height, data } = gray;
|
||||
const tmp = new Float32Array(data.length), out = new Float32Array(data.length);
|
||||
const win = 2 * r + 1;
|
||||
for (let y = 0; y < height; y++) {
|
||||
let acc = 0;
|
||||
for (let x = -r; x <= r; x++) acc += data[y * width + Math.min(width - 1, Math.max(0, x))];
|
||||
for (let x = 0; x < width; x++) {
|
||||
tmp[y * width + x] = acc / win;
|
||||
const outX = x - r, inX = x + r + 1;
|
||||
acc += data[y * width + Math.min(width - 1, inX)] - data[y * width + Math.max(0, outX)];
|
||||
}
|
||||
}
|
||||
for (let x = 0; x < width; x++) {
|
||||
let acc = 0;
|
||||
for (let y = -r; y <= r; y++) acc += tmp[Math.min(height - 1, Math.max(0, y)) * width + x];
|
||||
for (let y = 0; y < height; y++) {
|
||||
out[y * width + x] = acc / win;
|
||||
const outY = y - r, inY = y + r + 1;
|
||||
acc += tmp[Math.min(height - 1, inY) * width + x] - tmp[Math.max(0, outY) * width + x];
|
||||
}
|
||||
}
|
||||
return { width, height, data: out };
|
||||
}
|
||||
|
||||
/** Global SSIM between two same-size gray images using an 8x8 window grid. */
|
||||
export function ssim(a, b, win = 8) {
|
||||
if (a.width !== b.width || a.height !== b.height) throw new Error('ssim: size mismatch');
|
||||
const C1 = (0.01 * 255) ** 2, C2 = (0.03 * 255) ** 2;
|
||||
let total = 0, n = 0;
|
||||
for (let y = 0; y + win <= a.height; y += win) {
|
||||
for (let x = 0; x + win <= a.width; x += win) {
|
||||
let ma = 0, mb = 0;
|
||||
for (let yy = 0; yy < win; yy++) for (let xx = 0; xx < win; xx++) { const i = (y + yy) * a.width + x + xx; ma += a.data[i]; mb += b.data[i]; }
|
||||
ma /= win * win; mb /= win * win;
|
||||
let va = 0, vb = 0, cov = 0;
|
||||
for (let yy = 0; yy < win; yy++) for (let xx = 0; xx < win; xx++) { const i = (y + yy) * a.width + x + xx; const da = a.data[i] - ma, db = b.data[i] - mb; va += da * da; vb += db * db; cov += da * db; }
|
||||
va /= win * win - 1; vb /= win * win - 1; cov /= win * win - 1;
|
||||
total += ((2 * ma * mb + C1) * (2 * cov + C2)) / ((ma * ma + mb * mb + C1) * (va + vb + C2));
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n ? total / n : 1;
|
||||
}
|
||||
|
||||
/** SSIM of `a` against `b` shifted by (dx, dy); the overlap is compared, edges dropped. */
|
||||
export function ssimShifted(a, b, dx, dy, win = 8) {
|
||||
const w = a.width - Math.abs(dx), h = a.height - Math.abs(dy);
|
||||
if (w < win || h < win) return 0;
|
||||
const sa = { width: w, height: h, data: new Float32Array(w * h) };
|
||||
const sb = { width: w, height: h, data: new Float32Array(w * h) };
|
||||
const ax = Math.max(0, -dx), ay = Math.max(0, -dy), bx = Math.max(0, dx), by = Math.max(0, dy);
|
||||
for (let y = 0; y < h; y++) {
|
||||
sa.data.set(a.data.subarray((y + ay) * a.width + ax, (y + ay) * a.width + ax + w), y * w);
|
||||
sb.data.set(b.data.subarray((y + by) * b.width + bx, (y + by) * b.width + bx + w), y * w);
|
||||
}
|
||||
return ssim(sa, sb, win);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure score 0..1: SSIM over blurred grayscale at a fixed working width,
|
||||
* taking the best of a small translation search so a composition that sits a
|
||||
* few pixels off (a different masthead height, a scrollbar) is not read as a
|
||||
* different composition. Shifts up to ~4% of the width are forgiven; a moved
|
||||
* region is not.
|
||||
*/
|
||||
export function structureScore(imgA, imgB, workWidth = 256) {
|
||||
const h = Math.max(8, Math.round((imgA.height / imgA.width) * workWidth));
|
||||
const a = blurGray(toGray(resize(imgA, workWidth, h)), 2);
|
||||
const b = blurGray(toGray(resize(imgB, workWidth, h)), 2);
|
||||
const win = Math.min(8, Math.max(2, Math.floor(Math.min(workWidth, h) / 8)));
|
||||
let best = ssim(a, b, win);
|
||||
const maxShift = Math.max(2, Math.round(workWidth * 0.04));
|
||||
for (const dy of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
|
||||
for (const dx of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
|
||||
if (!dx && !dy) continue;
|
||||
best = Math.max(best, ssimShifted(a, b, Math.round(dx), Math.round(dy), win));
|
||||
}
|
||||
}
|
||||
return Math.max(0, Math.min(1, best));
|
||||
}
|
||||
|
||||
// ---- color -----------------------------------------------------------------
|
||||
|
||||
function rgbToLab(r, g, b) {
|
||||
const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
||||
const R = lin(r), G = lin(g), B = lin(b);
|
||||
const X = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
|
||||
const Y = (R * 0.2126 + G * 0.7152 + B * 0.0722) / 1.0;
|
||||
const Z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
|
||||
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
|
||||
const fx = f(X), fy = f(Y), fz = f(Z);
|
||||
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
||||
}
|
||||
|
||||
export function deltaE(lab1, lab2) {
|
||||
return Math.hypot(lab1[0] - lab2[0], lab1[1] - lab2[1], lab1[2] - lab2[2]);
|
||||
}
|
||||
|
||||
/** Quantized color histogram (4 bits per channel = 4096 bins), normalized. */
|
||||
export function colorHistogram(img, sampleStep = 2) {
|
||||
const bins = new Float32Array(4096);
|
||||
let n = 0;
|
||||
for (let y = 0; y < img.height; y += sampleStep) {
|
||||
for (let x = 0; x < img.width; x += sampleStep) {
|
||||
const p = (y * img.width + x) * 4;
|
||||
if (img.data[p + 3] < 16) continue;
|
||||
const key = ((img.data[p] >> 4) << 8) | ((img.data[p + 1] >> 4) << 4) | (img.data[p + 2] >> 4);
|
||||
bins[key]++; n++;
|
||||
}
|
||||
}
|
||||
if (n) for (let i = 0; i < bins.length; i++) bins[i] /= n;
|
||||
return bins;
|
||||
}
|
||||
|
||||
export function histogramIntersection(h1, h2) {
|
||||
let s = 0;
|
||||
for (let i = 0; i < h1.length; i++) s += Math.min(h1[i], h2[i]);
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dominant colors: merge histogram bins greedily by Lab distance into up to
|
||||
* `k` clusters and return them sorted by coverage.
|
||||
*/
|
||||
export function dominantColors(img, k = 6, sampleStep = 3) {
|
||||
const hist = colorHistogram(img, sampleStep);
|
||||
const entries = [];
|
||||
for (let i = 0; i < hist.length; i++) if (hist[i] > 0.0005) entries.push({ key: i, w: hist[i] });
|
||||
entries.sort((a, b) => b.w - a.w);
|
||||
const clusters = [];
|
||||
for (const e of entries) {
|
||||
const r = ((e.key >> 8) & 15) * 16 + 8, g = ((e.key >> 4) & 15) * 16 + 8, b = (e.key & 15) * 16 + 8;
|
||||
const lab = rgbToLab(r, g, b);
|
||||
let best = null, bestD = Infinity;
|
||||
for (const c of clusters) { const d = deltaE(c.lab, lab); if (d < bestD) { bestD = d; best = c; } }
|
||||
if (best && bestD < 14) {
|
||||
const tw = best.w + e.w;
|
||||
best.rgb = [(best.rgb[0] * best.w + r * e.w) / tw, (best.rgb[1] * best.w + g * e.w) / tw, (best.rgb[2] * best.w + b * e.w) / tw];
|
||||
best.lab = rgbToLab(...best.rgb); best.w = tw;
|
||||
} else clusters.push({ rgb: [r, g, b], lab, w: e.w });
|
||||
}
|
||||
clusters.sort((a, b) => b.w - a.w);
|
||||
const top = clusters.slice(0, k);
|
||||
const covered = top.reduce((s, c) => s + c.w, 0) || 1;
|
||||
return top.map((c) => ({ hex: toHex(c.rgb), coverage: +(c.w / covered).toFixed(4), lab: c.lab }));
|
||||
}
|
||||
|
||||
export function toHex(rgb) {
|
||||
return '#' + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Palette match 0..1: for each dominant comp color, coverage-weighted best
|
||||
* Lab match in the build's dominant set (dE 0 -> 1, dE >= 25 -> 0).
|
||||
*/
|
||||
export function paletteMatch(compColors, buildColors) {
|
||||
if (!compColors.length) return 1;
|
||||
let s = 0, wsum = 0;
|
||||
for (const c of compColors) {
|
||||
let best = Infinity;
|
||||
for (const b of buildColors) best = Math.min(best, deltaE(c.lab, b.lab));
|
||||
s += c.coverage * Math.max(0, 1 - best / 25); wsum += c.coverage;
|
||||
}
|
||||
return wsum ? s / wsum : 1;
|
||||
}
|
||||
|
||||
/** Color score 0..1: blend of histogram intersection and dominant-palette match. */
|
||||
export function colorScore(imgA, imgB) {
|
||||
const inter = histogramIntersection(colorHistogram(imgA), colorHistogram(imgB));
|
||||
const pm = paletteMatch(dominantColors(imgA), dominantColors(imgB));
|
||||
return { score: 0.35 * inter + 0.65 * pm, intersection: inter, paletteMatch: pm };
|
||||
}
|
||||
|
||||
// ---- detail ----------------------------------------------------------------
|
||||
|
||||
/** Mean absolute gradient (Sobel-lite) per cell over a cols x rows grid. */
|
||||
export function detailGrid(img, cols = 12, rows = 8, workWidth = 512) {
|
||||
const h = Math.max(rows, Math.round((img.height / img.width) * workWidth));
|
||||
const g = toGray(resize(img, workWidth, h));
|
||||
const grid = new Float32Array(cols * rows);
|
||||
const counts = new Float32Array(cols * rows);
|
||||
for (let y = 1; y < g.height - 1; y++) {
|
||||
const cy = Math.min(rows - 1, Math.floor((y / g.height) * rows));
|
||||
for (let x = 1; x < g.width - 1; x++) {
|
||||
const cx = Math.min(cols - 1, Math.floor((x / g.width) * cols));
|
||||
const i = y * g.width + x;
|
||||
const gx = Math.abs(g.data[i + 1] - g.data[i - 1]);
|
||||
const gy = Math.abs(g.data[i + g.width] - g.data[i - g.width]);
|
||||
grid[cy * cols + cx] += gx + gy; counts[cy * cols + cx]++;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < grid.length; i++) grid[i] = counts[i] ? grid[i] / counts[i] : 0;
|
||||
return { cols, rows, cells: grid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail score 0..1 and per-cell ratio. Cells where the comp is nearly flat
|
||||
* are ignored (nothing to lose); the score is the coverage-weighted mean of
|
||||
* min(1, build/comp) over cells with comp energy, so extra detail in the
|
||||
* build (invented chrome) is reported separately as `added`.
|
||||
*/
|
||||
export function detailScore(imgA, imgB, cols = 12, rows = 8) {
|
||||
const a = detailGrid(imgA, cols, rows), b = detailGrid(imgB, cols, rows);
|
||||
const floor = 1.5; // energy below this is a flat field at the 512px working width
|
||||
let s = 0, w = 0, added = 0, addedW = 0;
|
||||
const ratios = new Float32Array(cols * rows);
|
||||
for (let i = 0; i < a.cells.length; i++) {
|
||||
const ca = a.cells[i], cb = b.cells[i];
|
||||
ratios[i] = ca > floor ? cb / ca : (cb > floor ? Infinity : 1);
|
||||
// Signed: too much energy is as wrong as too little. Noise, a tile
|
||||
// shuffle, or a mosaic saturate a one-sided ratio; a real plate does not.
|
||||
if (ca > floor) { s += Math.min(cb / ca, ca / cb) * ca; w += ca; }
|
||||
if (cb > ca * 1.8 && cb > floor * 2) { added += 1; }
|
||||
addedW += 1;
|
||||
}
|
||||
const addedFraction = addedW ? added / addedW : 0;
|
||||
const raw = w ? s / w : 1;
|
||||
return { score: Math.max(0, raw - 0.5 * addedFraction), rawScore: raw, addedFraction, comp: a, build: b, ratios };
|
||||
}
|
||||
|
||||
// ---- pixel diff -----------------------------------------------------------
|
||||
|
||||
/** Per-pixel Lab-ish difference map (0..1) at a working width; blurred a little. */
|
||||
export function diffMap(imgA, imgB, workWidth = 384) {
|
||||
const h = Math.max(8, Math.round((imgA.height / imgA.width) * workWidth));
|
||||
const a = resize(imgA, workWidth, h), b = resize(imgB, workWidth, h);
|
||||
const out = new Float32Array(workWidth * h);
|
||||
for (let i = 0, p = 0; i < out.length; i++, p += 4) {
|
||||
const dr = a.data[p] - b.data[p], dg = a.data[p + 1] - b.data[p + 1], db = a.data[p + 2] - b.data[p + 2];
|
||||
out[i] = Math.min(1, Math.sqrt(dr * dr + dg * dg + db * db) / 200);
|
||||
}
|
||||
return blurGray({ width: workWidth, height: h, data: out }, 1);
|
||||
}
|
||||
|
||||
// ---- bands (horizontal layout structure) ---------------------------------
|
||||
|
||||
/**
|
||||
* Detect horizontal band boundaries: rows where the mean color changes
|
||||
* sharply. Returns normalized y positions (0..1) with strengths. This is the
|
||||
* "layout grid" read of a page: header / hero / index / footer as bands.
|
||||
*/
|
||||
export function horizontalBands(img, workWidth = 128, minGap = 0.02) {
|
||||
const h = Math.max(16, Math.round((img.height / img.width) * workWidth));
|
||||
const s = resize(img, workWidth, h);
|
||||
const rowMean = new Float32Array(h * 3);
|
||||
for (let y = 0; y < h; y++) {
|
||||
let r = 0, g = 0, b = 0;
|
||||
for (let x = 0; x < workWidth; x++) { const p = (y * workWidth + x) * 4; r += s.data[p]; g += s.data[p + 1]; b += s.data[p + 2]; }
|
||||
rowMean[y * 3] = r / workWidth; rowMean[y * 3 + 1] = g / workWidth; rowMean[y * 3 + 2] = b / workWidth;
|
||||
}
|
||||
const edges = [];
|
||||
for (let y = 1; y < h; y++) {
|
||||
const d = Math.hypot(rowMean[y * 3] - rowMean[(y - 1) * 3], rowMean[y * 3 + 1] - rowMean[(y - 1) * 3 + 1], rowMean[y * 3 + 2] - rowMean[(y - 1) * 3 + 2]);
|
||||
if (d > 18) edges.push({ y: y / h, strength: Math.min(1, d / 120) });
|
||||
}
|
||||
// merge close edges
|
||||
const merged = [];
|
||||
for (const e of edges) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && e.y - last.y < minGap) { if (e.strength > last.strength) { last.y = e.y; last.strength = e.strength; } }
|
||||
else merged.push({ ...e });
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Band agreement 0..1: fraction of comp bands with a build band within tolerance, and vice versa. */
|
||||
export function bandScore(bandsA, bandsB, tol = 0.04) {
|
||||
if (!bandsA.length && !bandsB.length) return 1;
|
||||
const match = (from, to) => from.filter((a) => to.some((b) => Math.abs(a.y - b.y) <= tol)).length;
|
||||
const recall = bandsA.length ? match(bandsA, bandsB) / bandsA.length : 1;
|
||||
const precision = bandsB.length ? match(bandsB, bandsA) / bandsB.length : 1;
|
||||
return 0.6 * recall + 0.4 * precision;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Convert a live-config glob pattern to a RegExp.
|
||||
*
|
||||
* Supports `**` across path segments, `*` within one segment, and `?` for one
|
||||
* character. Callers normalize project-relative paths to forward slashes.
|
||||
*/
|
||||
export function livePathGlobToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += `\\${c}`;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp(`^${re}$`);
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
/**
|
||||
* Dependency-free PNG decode/encode for the skill scripts.
|
||||
*
|
||||
* decodePng(buffer) -> { width, height, data } where data is RGBA8 (Uint8Array,
|
||||
* width*height*4). Handles every color type (0, 2, 3, 4, 6), bit depths 1-16
|
||||
* (16-bit is reduced to 8), all five filters, and Adam7 interlacing.
|
||||
*
|
||||
* encodePng({ width, height, data }) -> Buffer, RGBA8 in, 8-bit RGBA PNG out.
|
||||
*
|
||||
* Kept small on purpose: the skill scripts ship without npm dependencies, and
|
||||
* comps (gpt-image PNGs) and screenshots (Playwright / harness PNGs) are the
|
||||
* only formats the comp-fidelity tooling has to read.
|
||||
*/
|
||||
import zlib from 'node:zlib';
|
||||
import fs from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
const crcTable = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function crc32(data) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i++) c = crcTable[(c ^ data[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
export function isPng(buf) {
|
||||
return buf && buf.length > 8 && buf.subarray(0, 8).equals(SIGNATURE);
|
||||
}
|
||||
|
||||
function readChunks(buf) {
|
||||
const chunks = [];
|
||||
let pos = 8;
|
||||
while (pos + 8 <= buf.length) {
|
||||
const length = buf.readUInt32BE(pos);
|
||||
const type = buf.toString('latin1', pos + 4, pos + 8);
|
||||
const data = buf.subarray(pos + 8, pos + 8 + length);
|
||||
chunks.push({ type, data });
|
||||
pos += 12 + length;
|
||||
if (type === 'IEND') break;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
||||
|
||||
function paeth(a, b, c) {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
||||
if (pa <= pb && pa <= pc) return a;
|
||||
if (pb <= pc) return b;
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Unfilter one pass of scanlines in place; returns the raw (unfiltered) bytes. */
|
||||
function unfilter(raw, width, height, bpp, bitDepth, channels) {
|
||||
const stride = Math.ceil((width * channels * bitDepth) / 8);
|
||||
const out = new Uint8Array(stride * height);
|
||||
let inPos = 0;
|
||||
let prev = null;
|
||||
for (let y = 0; y < height; y++) {
|
||||
const filter = raw[inPos++];
|
||||
const line = out.subarray(y * stride, (y + 1) * stride);
|
||||
line.set(raw.subarray(inPos, inPos + stride));
|
||||
inPos += stride;
|
||||
switch (filter) {
|
||||
case 0: break;
|
||||
case 1: for (let i = bpp; i < stride; i++) line[i] = (line[i] + line[i - bpp]) & 0xff; break;
|
||||
case 2: if (prev) for (let i = 0; i < stride; i++) line[i] = (line[i] + prev[i]) & 0xff; break;
|
||||
case 3:
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const left = i >= bpp ? line[i - bpp] : 0;
|
||||
const up = prev ? prev[i] : 0;
|
||||
line[i] = (line[i] + ((left + up) >> 1)) & 0xff;
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const left = i >= bpp ? line[i - bpp] : 0;
|
||||
const up = prev ? prev[i] : 0;
|
||||
const ul = prev && i >= bpp ? prev[i - bpp] : 0;
|
||||
line[i] = (line[i] + paeth(left, up, ul)) & 0xff;
|
||||
}
|
||||
break;
|
||||
default: throw new Error(`png: unknown filter ${filter} on row ${y}`);
|
||||
}
|
||||
prev = line;
|
||||
}
|
||||
return { bytes: out, stride, consumed: inPos };
|
||||
}
|
||||
|
||||
/** Read sample `index` (0-based across the row) from a packed scanline. */
|
||||
function sampleReader(bitDepth) {
|
||||
if (bitDepth === 8) return (line, i) => line[i];
|
||||
if (bitDepth === 16) return (line, i) => line[i * 2]; // high byte
|
||||
const perByte = 8 / bitDepth;
|
||||
const mask = (1 << bitDepth) - 1;
|
||||
const scale = 255 / mask;
|
||||
return (line, i) => {
|
||||
const byte = line[(i / perByte) | 0];
|
||||
const shift = 8 - bitDepth * ((i % perByte) + 1);
|
||||
return Math.round(((byte >> shift) & mask) * scale);
|
||||
};
|
||||
}
|
||||
|
||||
function writePixels(dst, dstWidth, bytes, stride, passWidth, passHeight, colorType, bitDepth, palette, trns, mapX, mapY) {
|
||||
const channels = CHANNELS[colorType];
|
||||
const read = sampleReader(bitDepth);
|
||||
const rawIndex = bitDepth < 8 ? (line, i) => {
|
||||
const perByte = 8 / bitDepth;
|
||||
const mask = (1 << bitDepth) - 1;
|
||||
const byte = line[(i / perByte) | 0];
|
||||
const shift = 8 - bitDepth * ((i % perByte) + 1);
|
||||
return (byte >> shift) & mask;
|
||||
} : read;
|
||||
for (let y = 0; y < passHeight; y++) {
|
||||
const line = bytes.subarray(y * stride, (y + 1) * stride);
|
||||
const dy = mapY(y);
|
||||
for (let x = 0; x < passWidth; x++) {
|
||||
const dx = mapX(x);
|
||||
const o = (dy * dstWidth + dx) * 4;
|
||||
let r, g, b, a = 255;
|
||||
switch (colorType) {
|
||||
case 0: {
|
||||
r = g = b = read(line, x);
|
||||
if (trns && trns.gray === rawIndex(line, x)) a = 0;
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
r = read(line, x * 3); g = read(line, x * 3 + 1); b = read(line, x * 3 + 2);
|
||||
break;
|
||||
}
|
||||
case 3: {
|
||||
const idx = rawIndex(line, x);
|
||||
r = palette[idx * 3]; g = palette[idx * 3 + 1]; b = palette[idx * 3 + 2];
|
||||
if (trns && trns.alpha && idx < trns.alpha.length) a = trns.alpha[idx];
|
||||
break;
|
||||
}
|
||||
case 4: {
|
||||
r = g = b = read(line, x * 2); a = read(line, x * 2 + 1);
|
||||
break;
|
||||
}
|
||||
case 6: {
|
||||
r = read(line, x * 4); g = read(line, x * 4 + 1); b = read(line, x * 4 + 2); a = read(line, x * 4 + 3);
|
||||
break;
|
||||
}
|
||||
default: throw new Error(`png: unsupported color type ${colorType}`);
|
||||
}
|
||||
dst[o] = r; dst[o + 1] = g; dst[o + 2] = b; dst[o + 3] = a;
|
||||
}
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
export function decodePng(buf) {
|
||||
if (!isPng(buf)) throw new Error('png: not a PNG (bad signature)');
|
||||
const chunks = readChunks(buf);
|
||||
const ihdr = chunks.find((c) => c.type === 'IHDR');
|
||||
if (!ihdr) throw new Error('png: missing IHDR');
|
||||
const width = ihdr.data.readUInt32BE(0);
|
||||
const height = ihdr.data.readUInt32BE(4);
|
||||
const bitDepth = ihdr.data[8];
|
||||
const colorType = ihdr.data[9];
|
||||
const interlace = ihdr.data[12];
|
||||
const channels = CHANNELS[colorType];
|
||||
if (!channels) throw new Error(`png: unsupported color type ${colorType}`);
|
||||
const palChunk = chunks.find((c) => c.type === 'PLTE');
|
||||
const palette = palChunk ? palChunk.data : null;
|
||||
const trnsChunk = chunks.find((c) => c.type === 'tRNS');
|
||||
let trns = null;
|
||||
if (trnsChunk) {
|
||||
if (colorType === 3) trns = { alpha: trnsChunk.data };
|
||||
else if (colorType === 0) trns = { gray: trnsChunk.data.readUInt16BE(0) >> (bitDepth === 16 ? 8 : 0) };
|
||||
}
|
||||
const idat = Buffer.concat(chunks.filter((c) => c.type === 'IDAT').map((c) => c.data));
|
||||
const raw = zlib.inflateSync(idat);
|
||||
const bpp = Math.max(1, Math.ceil((channels * bitDepth) / 8));
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
const text = {};
|
||||
for (const c of chunks) {
|
||||
if (c.type === 'tEXt') {
|
||||
const z = c.data.indexOf(0);
|
||||
if (z > 0) text[c.data.toString('latin1', 0, z)] = c.data.toString('utf8', z + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (interlace === 0) {
|
||||
const { bytes, stride } = unfilter(raw, width, height, bpp, bitDepth, channels);
|
||||
writePixels(data, width, bytes, stride, width, height, colorType, bitDepth, palette, trns, (x) => x, (y) => y);
|
||||
} else {
|
||||
// Adam7
|
||||
const passes = [
|
||||
[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2],
|
||||
];
|
||||
let offset = 0;
|
||||
for (const [sx, sy, dx, dy] of passes) {
|
||||
const pw = Math.ceil((width - sx) / dx);
|
||||
const ph = Math.ceil((height - sy) / dy);
|
||||
if (pw <= 0 || ph <= 0) continue;
|
||||
const { bytes, stride, consumed } = unfilter(raw.subarray(offset), pw, ph, bpp, bitDepth, channels);
|
||||
offset += consumed;
|
||||
writePixels(data, width, bytes, stride, pw, ph, colorType, bitDepth, palette, trns, (x) => sx + x * dx, (y) => sy + y * dy);
|
||||
}
|
||||
}
|
||||
return { width, height, data, text };
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const typeBuf = Buffer.from(type, 'latin1');
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||||
return Buffer.concat([len, typeBuf, data, crc]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode RGBA8 to PNG. `text` (optional) is a map of tEXt keyword -> value.
|
||||
* Uses filter type 0 on every row: comps and screenshots compress fine and the
|
||||
* encoder stays trivial.
|
||||
*/
|
||||
export function encodePng({ width, height, data }, { text = null, level = 6 } = {}) {
|
||||
if (data.length !== width * height * 4) throw new Error(`png: data length ${data.length} != ${width}x${height}x4`);
|
||||
const stride = width * 4;
|
||||
const raw = Buffer.alloc((stride + 1) * height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (stride + 1)] = 0;
|
||||
raw.set(data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||||
const parts = [SIGNATURE, chunk('IHDR', ihdr)];
|
||||
if (text) {
|
||||
for (const [k, v] of Object.entries(text)) {
|
||||
parts.push(chunk('tEXt', Buffer.concat([Buffer.from(k, 'latin1'), Buffer.from([0]), Buffer.from(String(v), 'utf8')])));
|
||||
}
|
||||
}
|
||||
parts.push(chunk('IDAT', zlib.deflateSync(raw, { level })));
|
||||
parts.push(chunk('IEND', Buffer.alloc(0)));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read any raster the comp pipeline meets (PNG natively; WebP / JPEG / GIF /
|
||||
* AVIF through a converter on PATH) as RGBA. Non-PNG input is converted to a
|
||||
* sibling cache file `<name>.<ext>.png` next to the source, never in place:
|
||||
* a session that overwrites `comp.webp` with PNG bytes leaves a file the
|
||||
* next tool cannot trust and a transcript replay cannot reconstruct.
|
||||
* Returns { image, path } where path is the PNG actually decoded.
|
||||
*/
|
||||
export function loadRaster(file) {
|
||||
const buf = fs.readFileSync(file);
|
||||
if (isPng(buf)) return { image: decodePng(buf), path: file };
|
||||
const cache = `${file}.png`;
|
||||
if (fs.existsSync(cache)) {
|
||||
try { const b = fs.readFileSync(cache); if (isPng(b)) return { image: decodePng(b), path: cache }; } catch { /* reconvert */ }
|
||||
}
|
||||
const attempts = [
|
||||
['dwebp', [file, '-o', cache]],
|
||||
['sips', ['-s', 'format', 'png', file, '--out', cache]],
|
||||
['magick', [file, cache]],
|
||||
['convert', [file, cache]],
|
||||
];
|
||||
let lastErr = null;
|
||||
for (const [cmd, args] of attempts) {
|
||||
try { execFileSync(cmd, args, { stdio: 'ignore' }); const b = fs.readFileSync(cache); if (isPng(b)) return { image: decodePng(b), path: cache }; }
|
||||
catch (e) { lastErr = e; }
|
||||
}
|
||||
throw new Error(`png: ${file} is not a PNG and no converter (dwebp, sips, magick, convert) could produce ${cache}${lastErr ? `: ${lastErr.message}` : ''}`);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Small RGBA raster toolkit shared by the comp-fidelity scripts: crop, resize
|
||||
* (area-averaging down, bilinear up), composite, fills, rectangles, and a
|
||||
* bitmap-font label so composites can be captioned without a font stack.
|
||||
*
|
||||
* An image is `{ width, height, data }` with RGBA8 data (Uint8Array).
|
||||
*/
|
||||
|
||||
export function createImage(width, height, fill = [0, 0, 0, 0]) {
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
if (fill[0] || fill[1] || fill[2] || fill[3]) {
|
||||
for (let i = 0; i < data.length; i += 4) { data[i] = fill[0]; data[i + 1] = fill[1]; data[i + 2] = fill[2]; data[i + 3] = fill[3]; }
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
export function clampRect(img, x, y, w, h) {
|
||||
const x0 = Math.max(0, Math.min(img.width, Math.round(x)));
|
||||
const y0 = Math.max(0, Math.min(img.height, Math.round(y)));
|
||||
const x1 = Math.max(x0, Math.min(img.width, Math.round(x + w)));
|
||||
const y1 = Math.max(y0, Math.min(img.height, Math.round(y + h)));
|
||||
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
||||
}
|
||||
|
||||
export function crop(img, x, y, w, h) {
|
||||
const r = clampRect(img, x, y, w, h);
|
||||
const out = createImage(Math.max(1, r.w), Math.max(1, r.h));
|
||||
for (let yy = 0; yy < r.h; yy++) {
|
||||
const src = ((r.y + yy) * img.width + r.x) * 4;
|
||||
out.data.set(img.data.subarray(src, src + r.w * 4), yy * out.width * 4);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Resize with area averaging when shrinking and bilinear when growing. */
|
||||
export function resize(img, width, height) {
|
||||
width = Math.max(1, Math.round(width));
|
||||
height = Math.max(1, Math.round(height));
|
||||
if (width === img.width && height === img.height) return { width, height, data: new Uint8Array(img.data) };
|
||||
const out = createImage(width, height);
|
||||
const sx = img.width / width, sy = img.height / height;
|
||||
if (sx >= 1 && sy >= 1) {
|
||||
for (let y = 0; y < height; y++) {
|
||||
const y0 = Math.floor(y * sy), y1 = Math.min(img.height, Math.max(y0 + 1, Math.floor((y + 1) * sy)));
|
||||
for (let x = 0; x < width; x++) {
|
||||
const x0 = Math.floor(x * sx), x1 = Math.min(img.width, Math.max(x0 + 1, Math.floor((x + 1) * sx)));
|
||||
let r = 0, g = 0, b = 0, a = 0, n = 0;
|
||||
for (let yy = y0; yy < y1; yy++) {
|
||||
let p = (yy * img.width + x0) * 4;
|
||||
for (let xx = x0; xx < x1; xx++, p += 4) { r += img.data[p]; g += img.data[p + 1]; b += img.data[p + 2]; a += img.data[p + 3]; n++; }
|
||||
}
|
||||
const o = (y * width + x) * 4;
|
||||
out.data[o] = r / n; out.data[o + 1] = g / n; out.data[o + 2] = b / n; out.data[o + 3] = a / n;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for (let y = 0; y < height; y++) {
|
||||
const fy = Math.min(img.height - 1, (y + 0.5) * sy - 0.5);
|
||||
const y0 = Math.max(0, Math.floor(fy)), y1 = Math.min(img.height - 1, y0 + 1), wy = fy - y0;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const fx = Math.min(img.width - 1, (x + 0.5) * sx - 0.5);
|
||||
const x0 = Math.max(0, Math.floor(fx)), x1 = Math.min(img.width - 1, x0 + 1), wx = fx - x0;
|
||||
const o = (y * width + x) * 4;
|
||||
for (let c = 0; c < 4; c++) {
|
||||
const p00 = img.data[(y0 * img.width + x0) * 4 + c], p10 = img.data[(y0 * img.width + x1) * 4 + c];
|
||||
const p01 = img.data[(y1 * img.width + x0) * 4 + c], p11 = img.data[(y1 * img.width + x1) * 4 + c];
|
||||
out.data[o + c] = (p00 * (1 - wx) + p10 * wx) * (1 - wy) + (p01 * (1 - wx) + p11 * wx) * wy;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Scale to fit inside (maxW x maxH) preserving aspect; never upscale unless `allowUpscale`. */
|
||||
export function fit(img, maxW, maxH, allowUpscale = false) {
|
||||
const s = Math.min(maxW / img.width, maxH / img.height);
|
||||
if (s >= 1 && !allowUpscale) return img;
|
||||
return resize(img, img.width * s, img.height * s);
|
||||
}
|
||||
|
||||
/** Alpha-composite `src` onto `dst` at (x, y). */
|
||||
export function blit(dst, src, x, y) {
|
||||
x = Math.round(x); y = Math.round(y);
|
||||
for (let yy = 0; yy < src.height; yy++) {
|
||||
const dy = y + yy; if (dy < 0 || dy >= dst.height) continue;
|
||||
for (let xx = 0; xx < src.width; xx++) {
|
||||
const dx = x + xx; if (dx < 0 || dx >= dst.width) continue;
|
||||
const s = (yy * src.width + xx) * 4, d = (dy * dst.width + dx) * 4;
|
||||
const a = src.data[s + 3] / 255;
|
||||
if (a >= 1) { dst.data[d] = src.data[s]; dst.data[d + 1] = src.data[s + 1]; dst.data[d + 2] = src.data[s + 2]; dst.data[d + 3] = 255; continue; }
|
||||
if (a <= 0) continue;
|
||||
const da = dst.data[d + 3] / 255, oa = a + da * (1 - a);
|
||||
for (let c = 0; c < 3; c++) dst.data[d + c] = (src.data[s + c] * a + dst.data[d + c] * da * (1 - a)) / (oa || 1);
|
||||
dst.data[d + 3] = oa * 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function fillRect(img, x, y, w, h, rgba) {
|
||||
const r = clampRect(img, x, y, w, h);
|
||||
const a = (rgba[3] ?? 255) / 255;
|
||||
for (let yy = r.y; yy < r.y + r.h; yy++) {
|
||||
for (let xx = r.x; xx < r.x + r.w; xx++) {
|
||||
const o = (yy * img.width + xx) * 4;
|
||||
if (a >= 1) { img.data[o] = rgba[0]; img.data[o + 1] = rgba[1]; img.data[o + 2] = rgba[2]; img.data[o + 3] = 255; }
|
||||
else { for (let c = 0; c < 3; c++) img.data[o + c] = rgba[c] * a + img.data[o + c] * (1 - a); img.data[o + 3] = Math.max(img.data[o + 3], a * 255); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function strokeRect(img, x, y, w, h, rgba, thickness = 2) {
|
||||
fillRect(img, x, y, w, thickness, rgba);
|
||||
fillRect(img, x, y + h - thickness, w, thickness, rgba);
|
||||
fillRect(img, x, y, thickness, h, rgba);
|
||||
fillRect(img, x + w - thickness, y, thickness, h, rgba);
|
||||
}
|
||||
|
||||
// 5x7 bitmap font, uppercase + digits + a little punctuation. Enough for labels.
|
||||
const GLYPHS = {
|
||||
A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
|
||||
B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
|
||||
C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
|
||||
D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
|
||||
E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
|
||||
F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
|
||||
G: ['01110', '10001', '10000', '10111', '10001', '10001', '01111'],
|
||||
H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
|
||||
I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
|
||||
J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'],
|
||||
K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
|
||||
L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
|
||||
M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
|
||||
N: ['10001', '10001', '11001', '10101', '10011', '10001', '10001'],
|
||||
O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
|
||||
Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'],
|
||||
R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
|
||||
S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
|
||||
T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
|
||||
U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
|
||||
W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'],
|
||||
X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
|
||||
Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
|
||||
Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
|
||||
0: ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
|
||||
1: ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
|
||||
2: ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
|
||||
3: ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
|
||||
4: ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
|
||||
5: ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
|
||||
6: ['00110', '01000', '10000', '11110', '10001', '10001', '01110'],
|
||||
7: ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
|
||||
8: ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
|
||||
9: ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
|
||||
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
|
||||
'.': ['00000', '00000', '00000', '00000', '00000', '01100', '01100'],
|
||||
':': ['00000', '01100', '01100', '00000', '01100', '01100', '00000'],
|
||||
'-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
|
||||
'/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'],
|
||||
'%': ['11001', '11010', '00010', '00100', '01000', '01011', '10011'],
|
||||
'(': ['00010', '00100', '01000', '01000', '01000', '00100', '00010'],
|
||||
')': ['01000', '00100', '00010', '00010', '00010', '00100', '01000'],
|
||||
'#': ['01010', '01010', '11111', '01010', '11111', '01010', '01010'],
|
||||
'_': ['00000', '00000', '00000', '00000', '00000', '00000', '11111'],
|
||||
'?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'],
|
||||
'=': ['00000', '00000', '11111', '00000', '11111', '00000', '00000'],
|
||||
'+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'],
|
||||
',': ['00000', '00000', '00000', '00000', '01100', '00100', '01000'],
|
||||
};
|
||||
|
||||
export function textWidth(text, scale = 2) {
|
||||
return text.length * 6 * scale;
|
||||
}
|
||||
|
||||
/** Draw uppercase bitmap text. Returns width drawn. */
|
||||
export function drawText(img, text, x, y, rgba, scale = 2) {
|
||||
let cx = Math.round(x);
|
||||
for (const chRaw of String(text).toUpperCase()) {
|
||||
const g = GLYPHS[chRaw] || GLYPHS['?'];
|
||||
for (let r = 0; r < 7; r++) for (let c = 0; c < 5; c++) if (g[r][c] === '1') fillRect(img, cx + c * scale, y + r * scale, scale, scale, rgba);
|
||||
cx += 6 * scale;
|
||||
}
|
||||
return cx - x;
|
||||
}
|
||||
|
||||
/** Draw a label with a background pill. */
|
||||
export function drawLabel(img, text, x, y, { fg = [255, 255, 255, 255], bg = [0, 0, 0, 220], scale = 2, pad = 4 } = {}) {
|
||||
const w = textWidth(text, scale) + pad * 2, h = 7 * scale + pad * 2;
|
||||
fillRect(img, x, y, w, h, bg);
|
||||
drawText(img, text, x + pad, y + pad, fg, scale);
|
||||
return { w, h };
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Browser-side resolution of project detector waivers for Impeccable live mode.
|
||||
*
|
||||
* The live server serializes `.impeccable/config.json` + `config.local.json`
|
||||
* detector ignores (plus the served-root prefixes from the inject config's
|
||||
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
|
||||
* resolves that config against the current page's URL path when a detect scan
|
||||
* starts, so the overlay suppresses the same findings the CLI and the edit
|
||||
* hook do (issue #639).
|
||||
*
|
||||
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
|
||||
* 1. `ignoreRules` suppress a rule project-wide.
|
||||
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
|
||||
* files their globs name. The CLI never applies an unscoped wildcard
|
||||
* (isIgnoredFindingValue returns false for it), so neither does this.
|
||||
* 3. Remaining `ignoreValues` entries match on the finding's own value;
|
||||
* those are forwarded as `disabledValues` for the detector bundle to
|
||||
* apply where the findings are assembled.
|
||||
* 4. `ignoreFiles` globs that name the page waive it wholesale: the
|
||||
* resolver reports `skipScan: true` and the detector answers the scan
|
||||
* with zero findings, mirroring shouldIgnoreDetectionFile in the CLI
|
||||
* and the edit hook's own ignoreFiles gate.
|
||||
*
|
||||
* `pageFiles`, when the server could resolve it, lists the real project
|
||||
* files the inject config serves. A URL that suffix-matches exactly one of
|
||||
* them takes that file as its only project identity; an ambiguous or absent
|
||||
* match falls back to the served-root common ancestor below.
|
||||
*
|
||||
* Known gap, unchanged from PR #645: framework apps inject into source files
|
||||
* (src/routes/about/+page.svelte) while scans see route URLs (/about), so
|
||||
* entries scoped to source or asset paths never match a page candidate and
|
||||
* are dropped. That shows the finding, which is the conservative direction.
|
||||
*
|
||||
* Kept separate from live-browser.js so the glob and page-scope logic can be
|
||||
* unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
|
||||
* overlay UI bundle.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
if (!root) return;
|
||||
|
||||
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
|
||||
// cli/lib/impeccable-config.mjs.
|
||||
function normalizeIgnoreRule(rule) {
|
||||
return String(rule || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeIgnoreValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
||||
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
|
||||
function globToRegex(glob) {
|
||||
let re = '^';
|
||||
let i = 0;
|
||||
while (i < glob.length) {
|
||||
const c = glob[i];
|
||||
if (c === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
if (glob[i] === '/') i += 1;
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (c === '{') {
|
||||
const end = glob.indexOf('}', i);
|
||||
if (end === -1) { re += '\\{'; i += 1; continue; }
|
||||
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
||||
re += `(?:${parts.join('|')})`;
|
||||
i = end + 1;
|
||||
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
||||
re += `\\${c}`;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
re += '$';
|
||||
return new RegExp(re);
|
||||
}
|
||||
|
||||
// The project-relative paths this page could be known as. Ignore globs are
|
||||
// project-relative (prototype/foo.html) and the URL is site-relative
|
||||
// (/foo.html), because a static server's root usually sits inside the
|
||||
// project; `roots` carries that prefix. The server reads it from the inject
|
||||
// config's own `files` globs, which already state where the served pages
|
||||
// are. Do not derive it from the ignore globs: a single entry scoped to
|
||||
// prototype/library/** would then lend prototype/library/ as a candidate
|
||||
// prefix to every page, and that rule would suppress site-wide.
|
||||
//
|
||||
// Each prefixed path also contributes its slash suffixes, mirroring
|
||||
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
|
||||
// matches globs against every path suffix of the finding's file).
|
||||
//
|
||||
// One live session is served by one server, so a single document root must
|
||||
// sit at or above every configured page. The only prefix that can safely
|
||||
// be asserted is therefore the deepest common ancestor of the glob roots.
|
||||
// Treating each glob's own prefix as an identity goes wrong in both
|
||||
// directions: disjoint roots (src/ and public/) invent simultaneous
|
||||
// identities for one URL, so a waiver scoped to src/foo.html hides a
|
||||
// finding on a page served from public/foo.html; nested roots (prototype/
|
||||
// and prototype/library/, from globs at two depths in one tree) are not
|
||||
// alternatives at all, and demanding a waiver match under both stops
|
||||
// prototype/index.html from applying anywhere. When the globs share no
|
||||
// common root, no prefix is asserted and only the URL path itself matches.
|
||||
function pageCandidates(pathname, roots, pageFiles) {
|
||||
let pagePath = String(pathname || '');
|
||||
try {
|
||||
pagePath = decodeURIComponent(pagePath);
|
||||
} catch {
|
||||
// Malformed percent-escape: match on the raw path rather than throwing.
|
||||
}
|
||||
pagePath = pagePath.replace(/^\/+/, '');
|
||||
// A directory URL serves that directory's index, and the ignore globs
|
||||
// name files. Without this, /news/ never matches prototype/news/index.html.
|
||||
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
|
||||
|
||||
const candidates = new Set();
|
||||
const addSuffixes = (fullPath) => {
|
||||
const parts = fullPath.split('/').filter(Boolean);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
candidates.add(parts.slice(i).join('/'));
|
||||
}
|
||||
};
|
||||
addSuffixes(pagePath);
|
||||
|
||||
// The served page list names the real files the inject config serves.
|
||||
// A URL that suffix-matches exactly one of them has an unambiguous
|
||||
// project identity; assert that identity and stop guessing from roots
|
||||
// (PR #645 review: with src/ and public/ both served, /foo.html must not
|
||||
// borrow src/foo.html's waivers while actually serving public/foo.html).
|
||||
// Zero matches or several fall through to the common-ancestor fallback:
|
||||
// ambiguity resolves toward showing the finding.
|
||||
const knownPages = [];
|
||||
for (const entry of Array.isArray(pageFiles) ? pageFiles : []) {
|
||||
if (typeof entry !== 'string' || !entry) continue;
|
||||
if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry);
|
||||
}
|
||||
if (knownPages.length === 1) {
|
||||
addSuffixes(knownPages[0]);
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
const prefixes = [];
|
||||
for (const entry of Array.isArray(roots) ? roots : []) {
|
||||
if (typeof entry !== 'string') continue;
|
||||
prefixes.push(entry.split('/').filter(Boolean));
|
||||
}
|
||||
let common = prefixes.length > 0 ? prefixes[0] : [];
|
||||
for (const segments of prefixes.slice(1)) {
|
||||
let i = 0;
|
||||
while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1;
|
||||
common = common.slice(0, i);
|
||||
}
|
||||
|
||||
if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath);
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
function matchesScope(globs, candidates) {
|
||||
return globs.some((glob) => {
|
||||
let re;
|
||||
try {
|
||||
re = globToRegex(String(glob));
|
||||
} catch {
|
||||
// Malformed glob: skip it, as matchesAnyGlob does in the CLI.
|
||||
return false;
|
||||
}
|
||||
return candidates.some((candidate) => re.test(candidate));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the serialized project ignores for one page.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__,
|
||||
* in whatever state it arrived: absent, null, or hand-edited into the
|
||||
* wrong shape. Every read tolerates that and degrades to no filtering.
|
||||
* @param {string} options.pathname location.pathname of the scanned page.
|
||||
* @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}>, skipScan: boolean }}
|
||||
*/
|
||||
function resolveDetectIgnores({ ignores, pathname } = {}) {
|
||||
const config = ignores && typeof ignores === 'object' ? ignores : {};
|
||||
const asArray = (value) => (Array.isArray(value) ? value : []);
|
||||
const candidates = pageCandidates(pathname, config.roots, config.pageFiles);
|
||||
|
||||
// detector.ignoreFiles waives whole files. When any glob names this
|
||||
// page, the scan itself is skipped; rule and value lists are returned
|
||||
// empty because nothing will run.
|
||||
const ignoreFileGlobs = asArray(config.ignoreFiles)
|
||||
.filter((glob) => typeof glob === 'string' && glob.trim());
|
||||
if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) {
|
||||
return { disabledRules: [], disabledValues: [], skipScan: true };
|
||||
}
|
||||
|
||||
const disabledRules = new Set(
|
||||
asArray(config.ignoreRules)
|
||||
.filter((rule) => typeof rule === 'string')
|
||||
.map(normalizeIgnoreRule)
|
||||
.filter(Boolean),
|
||||
);
|
||||
const disabledValues = [];
|
||||
|
||||
for (const entry of asArray(config.ignoreValues)) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const rule = normalizeIgnoreRule(entry.rule);
|
||||
const value = normalizeIgnoreValue(entry.value);
|
||||
if (!rule || !value) continue;
|
||||
const files = [
|
||||
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
||||
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
|
||||
];
|
||||
if (value === '*') {
|
||||
// Wildcards suppress their rule only inside the files they name.
|
||||
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
|
||||
continue;
|
||||
}
|
||||
if (files.length > 0 && !matchesScope(files, candidates)) continue;
|
||||
disabledValues.push({ rule, value });
|
||||
}
|
||||
|
||||
return { disabledRules: [...disabledRules], disabledValues, skipScan: false };
|
||||
}
|
||||
|
||||
root.__IMPECCABLE_LIVE_IGNORES__ = {
|
||||
version: 1,
|
||||
resolveDetectIgnores,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -11143,36 +11143,10 @@ void main() {
|
||||
const scanId = String(++detectScanSeq);
|
||||
activeDetectScanId = scanId;
|
||||
pendingDetectScanId = scanId;
|
||||
// Send the project's detector waivers with the scan so the overlay
|
||||
// filters the same findings the CLI and the edit hook do (issue #639).
|
||||
// live-browser-ignores.js resolves .impeccable config for this page:
|
||||
// ignoreRules suppress outright, wildcard ignoreValues suppress their
|
||||
// rule in the files they name, ignoreFiles that name the page skip the
|
||||
// scan wholesale, and the rest match on the finding's own value inside
|
||||
// the detector. Guarded twice: a stale cached live.js without the
|
||||
// resolver part still scans, and a resolver that throws must not brick
|
||||
// the detect toggle; both degrade to an unfiltered scan.
|
||||
const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__;
|
||||
let ignores = { disabledRules: [], disabledValues: [], skipScan: false };
|
||||
if (typeof ignoresApi?.resolveDetectIgnores === 'function') {
|
||||
try {
|
||||
ignores = ignoresApi.resolveDetectIgnores({
|
||||
ignores: window.__IMPECCABLE_PROJECT_IGNORES__,
|
||||
pathname: location.pathname,
|
||||
}) || ignores;
|
||||
} catch (e) {
|
||||
ignores = { disabledRules: [], disabledValues: [], skipScan: false };
|
||||
}
|
||||
}
|
||||
window.postMessage({
|
||||
source: 'impeccable-command',
|
||||
action: 'scan',
|
||||
config: {
|
||||
scanId,
|
||||
disabledRules: ignores.disabledRules || [],
|
||||
disabledValues: ignores.disabledValues || [],
|
||||
skipScan: ignores.skipScan === true,
|
||||
},
|
||||
config: { scanId },
|
||||
}, '*');
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
|
||||
import { livePathGlobToRegex } from './lib/live-path-globs.mjs';
|
||||
import {
|
||||
describeInjectArtifacts,
|
||||
frameworkIgnorePatterns,
|
||||
@@ -365,7 +364,7 @@ export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(livePathGlobToRegex);
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
@@ -402,6 +401,47 @@ export function resolveFiles(rootDir, config) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
writeLiveServerInfo,
|
||||
} from './lib/impeccable-paths.mjs';
|
||||
import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
|
||||
import { collectProjectDetectorIgnores } from './live/project-ignores.mjs';
|
||||
import {
|
||||
createManualApplyController,
|
||||
summarizeManualApplyFailures,
|
||||
@@ -755,20 +754,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
|
||||
appRoot: process.cwd(),
|
||||
parts,
|
||||
// Read per request rather than cached, so editing the config and
|
||||
// reloading the tab is enough to pick up a new waiver. Config comes
|
||||
// from every root the session spans (appRoot, contextRoot, repoRoot):
|
||||
// in a monorepo the hook and the CLI key it at the repo root, which
|
||||
// is not the appRoot this process chdir'd onto.
|
||||
projectIgnores: collectProjectDetectorIgnores({
|
||||
appRoot: process.cwd(),
|
||||
contextRoot: LIVE_ROOTS?.contextRoot,
|
||||
repoRoot: LIVE_ROOTS?.repoRoot,
|
||||
scriptsDir: __dirname,
|
||||
}),
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
||||
'Pragma': 'no-cache',
|
||||
});
|
||||
@@ -777,7 +765,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; charset=utf-8' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
||||
res.end(detectScript);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import { fileURLToPath } from 'node:url';
|
||||
import { resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { livePathGlobToRegex } from './lib/live-path-globs.mjs';
|
||||
import { resolveSurfaceBrief } from './lib/surface-briefs.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
import { bootInstructions } from './live/instructions.mjs';
|
||||
@@ -241,7 +240,7 @@ function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
// Files matching the user's `exclude` globs are intentional omissions,
|
||||
// not drift. Compile them to regexes so the orphan list stays signal.
|
||||
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
||||
.map(livePathGlobToRegex);
|
||||
.map((p) => globToRegex(p));
|
||||
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
||||
|
||||
const orphans = [];
|
||||
@@ -279,6 +278,38 @@ function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
||||
* to avoid a circular import (live-inject.mjs already imports nothing
|
||||
* from live.mjs). The two must stay in sync.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
||||
else { re += '.*'; i += 2; }
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -6,7 +6,6 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs'
|
||||
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
|
||||
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
|
||||
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
|
||||
Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }),
|
||||
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
|
||||
]);
|
||||
|
||||
@@ -48,11 +47,6 @@ export function assembleLiveBrowserScript({
|
||||
// so tests can assemble with a stand-in.
|
||||
uiSurfaces = LIVE_UI_SURFACES,
|
||||
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
|
||||
// Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from
|
||||
// .impeccable config by live-server.mjs. live-browser-ignores.js resolves
|
||||
// them against the page when a detect scan starts, so the overlay filters
|
||||
// the same findings the CLI and the edit hook do (issue #639).
|
||||
projectIgnores = null,
|
||||
}) {
|
||||
const prelude =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
||||
@@ -72,8 +66,7 @@ export function assembleLiveBrowserScript({
|
||||
// repo's tests, the impeccable-site Live UI lab) import the module directly,
|
||||
// which is what keeps the two from drifting.
|
||||
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
|
||||
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` +
|
||||
`window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`;
|
||||
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
|
||||
|
||||
const body = parts.map((part) => {
|
||||
const file = part.file || path.basename(part.path || '');
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Project detector waivers for the live overlay (issue #639, hardened in the
|
||||
* PR #645 follow-up). One place decides what the /live.js prelude serializes
|
||||
* as window.__IMPECCABLE_PROJECT_IGNORES__:
|
||||
*
|
||||
* ignoreRules detector.ignoreRules, unioned across every live root.
|
||||
* ignoreValues detector.ignoreValues entries ({rule, value, files?}),
|
||||
* deduped across roots; createdAt/reason stay local.
|
||||
* ignoreFiles detector.ignoreFiles globs, unioned across roots, so a
|
||||
* wholly waived page scans to zero findings in the overlay
|
||||
* just as it reports nothing through the CLI and the hook.
|
||||
* roots served-root prefixes derived from the inject config's own
|
||||
* `files` globs. Never derived from the ignore globs: one
|
||||
* entry scoped to prototype/library/** would lend
|
||||
* prototype/library/ as a candidate prefix to every page,
|
||||
* and that rule would suppress site-wide (issue #639).
|
||||
* pageFiles the inject config's `files` expanded to real project
|
||||
* files, so the browser can resolve a URL to the one file it
|
||||
* actually serves instead of trying every root (PR #645
|
||||
* review: with src/ and public/ both served, /foo.html must
|
||||
* not borrow src/foo.html's waivers while actually serving
|
||||
* public/foo.html).
|
||||
*
|
||||
* Config is read from every root the live session spans: the appRoot the
|
||||
* server chdir'd onto, plus contextRoot and repoRoot when they differ. The
|
||||
* edit hook keys the same config at the session cwd (the repo root in a
|
||||
* monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its
|
||||
* invocation cwd, so reading only the appRoot silently dropped every waiver
|
||||
* in exactly the monorepo layouts the roots manifest exists for. Reading is
|
||||
* additive across roots, matching readConfig's own union of config.json and
|
||||
* config.local.json.
|
||||
*
|
||||
* In a monorepo, roots and pageFiles are serialized repo-relative (the
|
||||
* appRoot's path inside the repo is prefixed), so waivers spelled from
|
||||
* either root match through the resolver's suffix expansion.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { readConfig } from '../hook-lib.mjs';
|
||||
import { resolveFiles } from '../live-inject.mjs';
|
||||
import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs';
|
||||
|
||||
// Serializing thousands of page identities into every /live.js response
|
||||
// helps nobody; past this cap pageFiles is omitted and the resolver falls
|
||||
// back to the served-root common ancestor, which is correct, just less
|
||||
// precise about cross-root duplicates.
|
||||
const PAGE_FILES_CAP = 500;
|
||||
|
||||
export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) {
|
||||
const configRoots = [];
|
||||
for (const dir of [appRoot, contextRoot, repoRoot]) {
|
||||
if (typeof dir !== 'string' || !dir) continue;
|
||||
const resolved = path.resolve(dir);
|
||||
if (!configRoots.includes(resolved)) configRoots.push(resolved);
|
||||
}
|
||||
if (configRoots.length === 0) configRoots.push(process.cwd());
|
||||
|
||||
const ignoreRules = new Set();
|
||||
const ignoreFiles = new Set();
|
||||
const valueEntries = new Map();
|
||||
for (const dir of configRoots) {
|
||||
// readConfig merges config.json with the gitignored config.local.json
|
||||
// and type-checks both, exactly as the edit hook reads the same pair.
|
||||
const config = readConfig(dir);
|
||||
for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) {
|
||||
if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule);
|
||||
}
|
||||
for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) {
|
||||
if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob);
|
||||
}
|
||||
for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
// readConfig already normalized rule/value and folded `file` into
|
||||
// `files`; serve only what the browser matches on.
|
||||
const serialized = {
|
||||
rule: entry.rule,
|
||||
value: entry.value,
|
||||
...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}),
|
||||
};
|
||||
const key = JSON.stringify([serialized.rule, serialized.value,
|
||||
Array.isArray(serialized.files) ? [...serialized.files].sort() : []]);
|
||||
if (!valueEntries.has(key)) valueEntries.set(key, serialized);
|
||||
}
|
||||
}
|
||||
|
||||
const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir });
|
||||
return {
|
||||
ignoreRules: [...ignoreRules],
|
||||
ignoreValues: [...valueEntries.values()],
|
||||
ignoreFiles: [...ignoreFiles],
|
||||
roots: served.roots,
|
||||
pageFiles: served.pageFiles,
|
||||
};
|
||||
}
|
||||
|
||||
function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) {
|
||||
let live = null;
|
||||
try {
|
||||
const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir });
|
||||
live = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
} catch {
|
||||
// No readable inject config: the browser matches URL paths as-is.
|
||||
return { roots: [], pageFiles: [] };
|
||||
}
|
||||
const files = Array.isArray(live?.files)
|
||||
? live.files.filter((glob) => typeof glob === 'string' && glob)
|
||||
: [];
|
||||
|
||||
// A monorepo appRoot serializes identities repo-relative, so waivers
|
||||
// spelled from either root match through the resolver's suffix expansion.
|
||||
let prefix = '';
|
||||
if (typeof repoRoot === 'string' && repoRoot) {
|
||||
const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/');
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`;
|
||||
}
|
||||
|
||||
const roots = [...new Set(files.map((glob) => {
|
||||
const wildcardAt = glob.search(/[*?{]/);
|
||||
const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt);
|
||||
const cut = head.lastIndexOf('/');
|
||||
return prefix + (cut > -1 ? head.slice(0, cut + 1) : '');
|
||||
}))];
|
||||
|
||||
let pageFiles = [];
|
||||
try {
|
||||
pageFiles = resolveFiles(appRoot, { ...live, files })
|
||||
.filter((rel) => {
|
||||
// resolveFiles passes literal entries through even when they do not
|
||||
// exist; a missing file is nobody's identity.
|
||||
try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; }
|
||||
})
|
||||
.map((rel) => prefix + rel);
|
||||
} catch {
|
||||
pageFiles = [];
|
||||
}
|
||||
if (pageFiles.length > PAGE_FILES_CAP) pageFiles = [];
|
||||
|
||||
return { roots, pageFiles };
|
||||
}
|
||||
@@ -27,9 +27,6 @@
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
// Seeds are inlined (129 entries, hand-curated via a tinder review of
|
||||
// ~400 candidates from ColorHunt + synthesis + Radix/brand/Pantone anchors).
|
||||
@@ -497,15 +494,6 @@ function hueWord(H) {
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// The picker server imports SEEDS to serve /palettes.json; the CLI tail
|
||||
// below only runs when this file is the entry point, so importing it has
|
||||
// no side effects.
|
||||
export { SEEDS };
|
||||
|
||||
// argv[1] must be realpath'd: a skill installed via symlink makes argv[1]
|
||||
// the symlink path, which never equality-matches import.meta.url's realpath,
|
||||
// so the CLI would silently never run (same guard as visual-cues.mjs).
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(resolve(process.argv[1]))).href) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const seed = pickSeed(SEEDS, args);
|
||||
const [L, C, H] = seed.oklch;
|
||||
@@ -638,4 +626,3 @@ Dark text is correct only on PALE fills (L > 0.85) or PURE-NEUTRAL fills
|
||||
Return your composed palette in CSS custom properties using OKLCH, then
|
||||
build with it. The seed is the start, not the recipe.
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/** Agent poll CLI for the design-document edit session.
|
||||
*
|
||||
* The picker forks picker-doc-session.mjs on submit; this is how the agent
|
||||
* hears from it, on the live-poll.mjs contract: one-shot by default, block
|
||||
* until one event arrives, print it as JSON on stdout, exit.
|
||||
*
|
||||
* node picker-doc-poll.mjs # block, print one event
|
||||
* node picker-doc-poll.mjs --timeout=600000 # total budget in ms
|
||||
* node picker-doc-poll.mjs --reply <id> <status> [message]
|
||||
* node picker-doc-poll.mjs --reply <id> done "msg" --answers '{"key":"value"}'
|
||||
*
|
||||
* Events printed: {"type":"edit_request","id","kind","prompt","category",
|
||||
* "payload"} for work a person asked for in words,
|
||||
* {"type":"save_batch","id","changes","downstream","replyCommand"} for edits
|
||||
* already applied to the store and owed a prose pass in DESIGN.md or
|
||||
* PRODUCT.md, {"type":"timeout"} when the budget runs out (poll again), and
|
||||
* {"type":"exit"} when the session ended (stop polling).
|
||||
*
|
||||
* Reply statuses: done (change applied; message shown to the user in the
|
||||
* document), error (could not apply; message explains), retry (release the
|
||||
* request back to pending).
|
||||
*
|
||||
* --answers and --context attach values for the session to write. The session
|
||||
* is the only writer of the store while it runs, so a value the agent settles
|
||||
* travels here rather than being written to those files directly.
|
||||
*
|
||||
* Session discovery: .impeccable/design-context/runtime/session.json, written
|
||||
* by the session process and removed when it exits; a missing file prints
|
||||
* {"type":"exit"} so a finished session never hangs the loop.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { paths } from './design-context/store.mjs';
|
||||
|
||||
const sessionPath = paths(process.cwd()).sessionJson;
|
||||
/* Sliced under undici's 300s header timeout, same as live-poll. */
|
||||
const PER_REQUEST_MS = 270_000;
|
||||
const DEFAULT_TOTAL_MS = 600_000;
|
||||
|
||||
async function session() {
|
||||
try {
|
||||
return JSON.parse(await readFile(sessionPath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function readFlag(name, fallback) {
|
||||
const exact = args.find((arg) => arg.startsWith(`${name}=`));
|
||||
if (exact) return exact.slice(name.length + 1);
|
||||
const at = args.indexOf(name);
|
||||
if (at !== -1 && args[at + 1]) return args[at + 1];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const info = await session();
|
||||
if (!info) {
|
||||
console.log(JSON.stringify({ type: 'exit', reason: 'no-session' }));
|
||||
process.exit(0);
|
||||
}
|
||||
const base = `http://127.0.0.1:${info.port}`;
|
||||
|
||||
const VALUE_FLAGS = new Set(['--answers', '--context', '--timeout']);
|
||||
|
||||
/* The message is whatever positional words are left, so a flag and the value
|
||||
that belongs to it both have to come out first, or an attached JSON payload
|
||||
would be read back to the user as their confirmation line. */
|
||||
function positionalAfter(marker) {
|
||||
const words = [];
|
||||
for (let index = args.indexOf(marker) + 1; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg.startsWith('--')) {
|
||||
if (VALUE_FLAGS.has(arg)) index += 1;
|
||||
continue;
|
||||
}
|
||||
words.push(arg);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
if (args.includes('--reply')) {
|
||||
const [id, status, ...rest] = positionalAfter('--reply');
|
||||
if (!id || !status) {
|
||||
console.error('usage: picker-doc-poll.mjs --reply <id> <done|error|retry> [message] [--answers JSON] [--context JSON]');
|
||||
process.exit(1);
|
||||
}
|
||||
/* Values the agent settled while doing the work, handed to the session to
|
||||
write. Bad JSON is a mistake worth stopping for rather than dropping. */
|
||||
const attached = {};
|
||||
for (const flag of ['answers', 'context']) {
|
||||
const raw = readFlag(`--${flag}`, '');
|
||||
if (!raw) continue;
|
||||
try {
|
||||
attached[flag] = JSON.parse(raw);
|
||||
} catch {
|
||||
console.error(`--${flag} must be a JSON object`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const response = await fetch(`${base}/doc/reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: info.token, id, status, message: rest.join(' '), ...attached }),
|
||||
}).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
console.error(`Reply failed: ${response ? response.status : 'session unreachable'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify(await response.json()));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const totalBudget = Number(readFlag('--timeout', DEFAULT_TOTAL_MS));
|
||||
const deadline = Date.now() + (Number.isFinite(totalBudget) && totalBudget > 0 ? totalBudget : DEFAULT_TOTAL_MS);
|
||||
|
||||
for (;;) {
|
||||
const slice = Math.min(deadline - Date.now(), PER_REQUEST_MS);
|
||||
if (slice <= 0) {
|
||||
console.log(JSON.stringify({ type: 'timeout' }));
|
||||
process.exit(0);
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
const response = await fetch(`${base}/doc/poll?token=${encodeURIComponent(info.token)}&timeout=${slice}`);
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
/* The session process exited between polls. */
|
||||
console.log(JSON.stringify({ type: 'exit', reason: 'session-gone' }));
|
||||
process.exit(0);
|
||||
}
|
||||
if (payload.type === 'timeout') continue;
|
||||
console.log(JSON.stringify(payload));
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/** Design-document edit session (self-contained, zero dependencies).
|
||||
*
|
||||
* The picker server forks this detached sibling the moment the questionnaire
|
||||
* submits, so the review tab's design context document stays connected after
|
||||
* the picker itself exits 0 (the agent's completion signal). It runs on its
|
||||
* own pre-scanned port with CORS open to the picker origin, and it mediates
|
||||
* three parties the way the live server does, scaled down to polling:
|
||||
*
|
||||
* browser --POST /doc/save----------> applied to the store, batch queued
|
||||
* browser --POST /doc/request-------> queue --GET /doc/poll--> agent
|
||||
* agent --POST /doc/reply---------> queue status + version bump
|
||||
* browser --GET /doc/state (poll)--> { version, requests, batch } -> re-read
|
||||
*
|
||||
* It also serves the document's own images: GET /brand-assets/* for what the
|
||||
* user supplied, GET /assets/* for the ones the picker ships. Both are
|
||||
* token-gated and read-only. They are here rather than on the picker server
|
||||
* because article images load when a view opens, which is always after the
|
||||
* picker has exited.
|
||||
*
|
||||
* Edits made in the document stage in the browser and arrive here as one batch.
|
||||
* Applying them is deterministic and belongs to this process: each change names
|
||||
* a field, the field names a place in the store, and the value is written
|
||||
* there. What reaches the agent afterwards is the reconciliation the store
|
||||
* cannot do for itself, the prose in DESIGN.md and PRODUCT.md that describes
|
||||
* those values. Anything needing judgment up front, a font change or a freeform
|
||||
* ask, queues for the agent the same way, and it long-polls through
|
||||
* picker-doc-poll.mjs exactly like live mode's live-poll.mjs.
|
||||
*
|
||||
* This process is the only writer of the store while it runs; the agent's own
|
||||
* follow-on values ride in on its reply. That is what keeps a save and an
|
||||
* agent working at the same time from overwriting each other.
|
||||
*
|
||||
* Session discovery for the agent CLI: .impeccable/design-context/runtime/
|
||||
* session.json { pid, port, token }. Removed on exit. Every applied change is
|
||||
* journaled to runtime/journal.jsonl beside it, so a session that dies with a
|
||||
* batch outstanding re-offers it and the agent can reconcile prose at the end.
|
||||
*
|
||||
* Usage (spawned by picker-server.mjs, not by hand):
|
||||
* node picker-doc-session.mjs --port 8501 --timeout 60
|
||||
* with IMPECCABLE_DOC_TOKEN in the environment.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fontRelativePath, migrate, paths, readJsonSoft, writeJsonAtomic } from './design-context/store.mjs';
|
||||
import { createSaveRoutes } from './design-context/session-routes.mjs';
|
||||
|
||||
const store = paths(process.cwd());
|
||||
const answersPath = store.answersJson;
|
||||
const contextPath = store.contextJson;
|
||||
const sessionPath = store.sessionJson;
|
||||
const fontsDir = store.fontsDir;
|
||||
const brandAssetsDir = store.assetsDir;
|
||||
/* The built picker beside this script: the document's own images (section
|
||||
foils, rail textures, placeholder brand assets) are served from here after
|
||||
the submit-flow picker server has exited. Read-only, image types only. */
|
||||
const pickerAssetsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'picker', 'assets');
|
||||
|
||||
/* The Hooks page reads and writes hook config through hook-admin.mjs, the one
|
||||
writer whose shapes stay validated; this server only ferries JSON either
|
||||
way. Sync is fine: the admin runs in milliseconds, holds no sockets, and a
|
||||
moment of backpressure on the doc port costs nothing. */
|
||||
const hookAdminScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'hook-admin.mjs');
|
||||
function runHookAdmin(args, input) {
|
||||
const stdout = execFileSync(process.execPath, [hookAdminScript, ...args], {
|
||||
cwd: process.cwd(),
|
||||
input: input ?? '',
|
||||
encoding: 'utf-8',
|
||||
timeout: 15_000,
|
||||
});
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
|
||||
const BRAND_ASSET_MIME = new Map([
|
||||
['.svg', 'image/svg+xml'],
|
||||
['.png', 'image/png'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.webp', 'image/webp'],
|
||||
['.gif', 'image/gif'],
|
||||
]);
|
||||
const PICKER_ASSET_MIME = new Map([
|
||||
['.png', 'image/png'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.webp', 'image/webp'],
|
||||
['.svg', 'image/svg+xml'],
|
||||
]);
|
||||
const REQUEST_KINDS = new Set(['font', 'freeform']);
|
||||
/* Long polls are sliced under common proxy/undici header timeouts, the same
|
||||
270s ceiling live-poll uses. */
|
||||
const MAX_POLL_MS = 270_000;
|
||||
/* The tab polls /doc/state every couple of seconds while open; when it has
|
||||
been quiet this long the session is over and the agent's poll gets exit. */
|
||||
const BROWSER_GONE_MS = 10 * 60_000;
|
||||
/* A tab adopts the session within seconds of the submit that forked it. If
|
||||
no poll ever arrives (a test harness, a closed tab), die young instead of
|
||||
holding a port for the full ceiling. */
|
||||
const ADOPT_GRACE_MS = 90_000;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const readArg = (name, fallback) => {
|
||||
const at = args.indexOf(name);
|
||||
return at !== -1 && args[at + 1] ? args[at + 1] : fallback;
|
||||
};
|
||||
const port = Number(readArg('--port', '0'));
|
||||
const timeoutMinutes = Number(readArg('--timeout', '60'));
|
||||
const token = process.env.IMPECCABLE_DOC_TOKEN || '';
|
||||
if (!port || !token) {
|
||||
console.error('picker-doc-session is spawned by picker-server.mjs and needs --port plus IMPECCABLE_DOC_TOKEN.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let version = 1;
|
||||
let requestSeq = 0;
|
||||
const requests = [];
|
||||
/* The save flow lives in its own module; this shell keeps the server, the
|
||||
timers, and the token. Every applied save bumps the same version the tab
|
||||
polls, so the document re-reads itself without a second signal. */
|
||||
const saves = createSaveRoutes({ onChange: () => { bumpVersion(); wakeParkedPolls(); } });
|
||||
let lastBrowserSeen = Date.now();
|
||||
let adopted = false;
|
||||
const parkedPolls = [];
|
||||
|
||||
function sendJson(response, statusCode, body) {
|
||||
response.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function httpError(statusCode, message) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function readJsonBody(request) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) throw httpError(413, 'Request body exceeds 1 MB');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
} catch {
|
||||
throw httpError(400, 'Body must be valid JSON');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw httpError(400, 'Body must be a JSON object');
|
||||
return value;
|
||||
}
|
||||
|
||||
const summarize = (entry) => ({
|
||||
id: entry.id,
|
||||
kind: entry.kind,
|
||||
prompt: entry.prompt,
|
||||
category: entry.category,
|
||||
status: entry.status,
|
||||
message: entry.message || '',
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
Requests that need judgment, queued for the agent.
|
||||
============================================================ */
|
||||
|
||||
function wakeParkedPolls() {
|
||||
while (parkedPolls.length) {
|
||||
const parked = parkedPolls.shift();
|
||||
clearTimeout(parked.timer);
|
||||
parked.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPending() {
|
||||
return requests.find((entry) => entry.status === 'pending');
|
||||
}
|
||||
|
||||
async function handleDocPoll(response, query) {
|
||||
const budget = Math.min(Number(query.get('timeout')) || MAX_POLL_MS, MAX_POLL_MS);
|
||||
const deadline = Date.now() + budget;
|
||||
|
||||
for (;;) {
|
||||
if (Date.now() - lastBrowserSeen > BROWSER_GONE_MS) {
|
||||
sendJson(response, 200, { type: 'exit', reason: 'browser-gone' });
|
||||
return;
|
||||
}
|
||||
const entry = nextPending();
|
||||
if (entry) {
|
||||
entry.status = 'working';
|
||||
bumpVersion();
|
||||
sendJson(response, 200, { type: 'edit_request', ...summarize(entry), payload: entry.payload });
|
||||
return;
|
||||
}
|
||||
/* The values are already in the store; what is handed over is the prose
|
||||
still owed to DESIGN.md and PRODUCT.md. The reply command travels with
|
||||
the event so the instruction cannot drift from the contract. */
|
||||
const batch = saves.takeBatchEvent((id) => `node picker-doc-poll.mjs --reply ${id} done "One line the user sees in the tab"`);
|
||||
if (batch) {
|
||||
sendJson(response, 200, batch);
|
||||
return;
|
||||
}
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) {
|
||||
sendJson(response, 200, { type: 'timeout' });
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
const parked = { resolve, timer: setTimeout(resolve, Math.min(remaining, 5_000)) };
|
||||
parkedPolls.push(parked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bumpVersion() {
|
||||
version += 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Server
|
||||
============================================================ */
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
void handleRequest(request, response).catch((error) => {
|
||||
if (!response.headersSent) sendJson(response, error.statusCode || 500, { error: error.message });
|
||||
else response.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
async function handleRequest(request, response) {
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const requestPath = url.pathname;
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
response.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, X-Font-Filename',
|
||||
'Access-Control-Max-Age': '600',
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
/* Font uploads carry bytes, not JSON; token rides the query string. */
|
||||
if (request.method === 'POST' && requestPath === '/font-upload') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
const name = path.basename(request.headers['x-font-filename'] || '');
|
||||
if (!name || !FONT_EXTENSIONS.has(path.extname(name).toLowerCase())) {
|
||||
throw httpError(400, 'Expected a .woff2, .woff, .ttf, or .otf filename');
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) throw httpError(413, 'Font exceeds 1 MB');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
await mkdir(fontsDir, { recursive: true });
|
||||
await writeFile(path.join(fontsDir, name), Buffer.concat(chunks));
|
||||
sendJson(response, 200, { ok: true, path: fontRelativePath(name) });
|
||||
return;
|
||||
}
|
||||
|
||||
/* Brand-asset images for the document's Brand article. The picker server
|
||||
serves the same directory while it lives; it exits on submit, and the
|
||||
article's images load after that, so the tab fetches them from here
|
||||
with the session token on the query string, the same rule as the
|
||||
sibling GET routes. Filenames only, extension-gated, one directory. */
|
||||
if (request.method === 'GET' && requestPath.startsWith('/brand-assets/')) {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
let assetName;
|
||||
try {
|
||||
assetName = decodeURIComponent(requestPath.slice('/brand-assets/'.length));
|
||||
} catch {
|
||||
throw httpError(400, 'Invalid path');
|
||||
}
|
||||
const extension = path.extname(assetName).toLowerCase();
|
||||
const filePath = path.resolve(brandAssetsDir, assetName);
|
||||
if (!assetName || assetName !== path.basename(assetName)
|
||||
|| !BRAND_ASSET_MIME.has(extension)
|
||||
|| path.relative(brandAssetsDir, filePath).startsWith('..')) {
|
||||
throw httpError(404, 'Not found');
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = await readFile(filePath);
|
||||
} catch {
|
||||
throw httpError(404, 'Not found');
|
||||
}
|
||||
response.writeHead(200, {
|
||||
'Content-Type': BRAND_ASSET_MIME.get(extension),
|
||||
'Content-Length': body.length,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=86400',
|
||||
});
|
||||
response.end(body);
|
||||
return;
|
||||
}
|
||||
|
||||
/* The chosen cue, copied into the store at submit (picker-server.mjs
|
||||
copyChosenCue). The components cards and the Color article render it after
|
||||
the picker server has exited, so the tab fetches it here: token-gated, one
|
||||
fixed file, PNG only, the trust model of the routes beside it. A run whose
|
||||
palette named no cue has no file, which is the 404. */
|
||||
if (request.method === 'GET' && requestPath === '/cue.png') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
let body;
|
||||
try {
|
||||
body = await readFile(store.cuePng);
|
||||
} catch {
|
||||
throw httpError(404, 'Not found');
|
||||
}
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': body.length,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=86400',
|
||||
});
|
||||
response.end(body);
|
||||
return;
|
||||
}
|
||||
|
||||
/* The document's own static images, for the tab that outlives the picker
|
||||
server: the submit flow exits on /submit, and article images only load when
|
||||
a view opens, which is always after that. Same trust model as the route
|
||||
above, token-gated and read-only, but contained rather than flat, because
|
||||
the vendored files sit in per-category subdirectories. */
|
||||
if (request.method === 'GET' && requestPath.startsWith('/assets/')) {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
let assetPath;
|
||||
try {
|
||||
assetPath = decodeURIComponent(requestPath.slice('/assets/'.length));
|
||||
} catch {
|
||||
throw httpError(400, 'Invalid path');
|
||||
}
|
||||
const extension = path.extname(assetPath).toLowerCase();
|
||||
const filePath = path.resolve(pickerAssetsDir, assetPath);
|
||||
const contained = path.relative(pickerAssetsDir, filePath);
|
||||
if (!assetPath
|
||||
|| assetPath.includes('\0')
|
||||
|| !PICKER_ASSET_MIME.has(extension)
|
||||
|| contained.startsWith('..')
|
||||
|| path.isAbsolute(contained)) {
|
||||
throw httpError(404, 'Not found');
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = await readFile(filePath);
|
||||
} catch {
|
||||
throw httpError(404, 'Not found');
|
||||
}
|
||||
response.writeHead(200, {
|
||||
'Content-Type': PICKER_ASSET_MIME.get(extension),
|
||||
'Content-Length': body.length,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=86400',
|
||||
});
|
||||
response.end(body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && requestPath === '/doc/state') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
lastBrowserSeen = Date.now();
|
||||
adopted = true;
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
version,
|
||||
requests: requests.map(summarize),
|
||||
agentWaiting: parkedPolls.length > 0,
|
||||
batch: saves.summary(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/* The Hooks page's live state: the shared config's hook switch and the
|
||||
detector ignore lists, read through hook-admin so this server never
|
||||
parses config shapes itself. */
|
||||
if (request.method === 'GET' && requestPath === '/doc/hooks') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
let state;
|
||||
try {
|
||||
state = runHookAdmin(['state']);
|
||||
} catch (error) {
|
||||
throw httpError(500, String(error.stderr || error.message || error).trim().split('\n')[0]);
|
||||
}
|
||||
sendJson(response, 200, { ok: true, state });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && requestPath === '/doc/answers') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
const answers = JSON.parse(await readFile(answersPath, 'utf8'));
|
||||
sendJson(response, 200, { ok: true, version, answers });
|
||||
return;
|
||||
}
|
||||
|
||||
/* The chat half of the run, read fresh so an agent's rewrite reaches the tab. */
|
||||
if (request.method === 'GET' && requestPath === '/doc/context') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
let stored = null;
|
||||
try {
|
||||
stored = JSON.parse(await readFile(contextPath, 'utf8'));
|
||||
} catch {
|
||||
/* A run whose chat half was never recorded still has a document. */
|
||||
}
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
version,
|
||||
modes: stored?.modes ?? null,
|
||||
context: stored?.context ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && requestPath === '/doc/poll') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
await handleDocPoll(response, url.searchParams);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method !== 'POST') throw httpError(404, 'Not found');
|
||||
const body = await readJsonBody(request);
|
||||
if (body.token !== token) throw httpError(403, 'Bad token');
|
||||
|
||||
/* Everything staged in the document arrives at once. Applying is this
|
||||
process's job; reconciling the prose around it is the agent's. */
|
||||
if (requestPath === '/doc/save') {
|
||||
const applied = await saves.save(body);
|
||||
sendJson(response, 200, { ok: true, version, ...applied });
|
||||
return;
|
||||
}
|
||||
|
||||
/* The Hooks page's Apply: the full desired state arrives at once and is
|
||||
written by hook-admin.mjs apply, with no model in the loop. Same
|
||||
deterministic contract as /doc/save: complete or refused, never
|
||||
approximately done. */
|
||||
if (requestPath === '/doc/hooks') {
|
||||
if (!body.state || typeof body.state !== 'object' || Array.isArray(body.state)) {
|
||||
throw httpError(400, 'state must be a JSON object');
|
||||
}
|
||||
let state;
|
||||
try {
|
||||
state = runHookAdmin(['apply'], JSON.stringify(body.state));
|
||||
} catch (error) {
|
||||
throw httpError(400, String(error.stderr || error.message || error).trim().split('\n')[0]);
|
||||
}
|
||||
sendJson(response, 200, { ok: true, state });
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestPath === '/doc/request') {
|
||||
if (!REQUEST_KINDS.has(body.kind)) throw httpError(400, 'kind must be font or freeform');
|
||||
const prompt = String(body.prompt || '').trim();
|
||||
if (!prompt || prompt.length > 4000) throw httpError(400, 'prompt is required, 4000 characters max');
|
||||
requestSeq += 1;
|
||||
const entry = {
|
||||
id: `req-${String(requestSeq).padStart(3, '0')}`,
|
||||
kind: body.kind,
|
||||
prompt,
|
||||
category: String(body.category || ''),
|
||||
payload: body.payload && typeof body.payload === 'object' ? body.payload : {},
|
||||
status: 'pending',
|
||||
message: '',
|
||||
};
|
||||
requests.push(entry);
|
||||
bumpVersion();
|
||||
wakeParkedPolls();
|
||||
sendJson(response, 200, { ok: true, id: entry.id, version });
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestPath === '/doc/reply') {
|
||||
// A save and a request are both replied to here, told apart by the id.
|
||||
if (saves.hasPending() && String(body.id || '').startsWith('batch-')) {
|
||||
const result = await saves.reply(body);
|
||||
sendJson(response, 200, { ok: true, version, ...result });
|
||||
return;
|
||||
}
|
||||
const entry = requests.find((item) => item.id === body.id);
|
||||
if (!entry) throw httpError(404, 'Unknown request id');
|
||||
if (!['done', 'error', 'retry'].includes(body.status)) throw httpError(400, 'status must be done, error, or retry');
|
||||
entry.status = body.status === 'retry' ? 'pending' : body.status;
|
||||
entry.message = String(body.message || '');
|
||||
/* Values attached to the reply land in the store here, same as a batch
|
||||
reply: the session stays the only writer while it runs. */
|
||||
const applied = entry.status === 'pending' ? null : await saves.applyAgentUpdates(body);
|
||||
saves.noteRequest(entry.id, entry.status);
|
||||
bumpVersion();
|
||||
if (entry.status === 'pending') wakeParkedPolls();
|
||||
sendJson(response, 200, { ok: true, version, applied });
|
||||
return;
|
||||
}
|
||||
|
||||
throw httpError(404, 'Not found');
|
||||
}
|
||||
|
||||
server.listen(port, '127.0.0.1', async () => {
|
||||
await migrate(process.cwd());
|
||||
await writeJsonAtomic(sessionPath, { pid: process.pid, port, token });
|
||||
});
|
||||
|
||||
server.on('error', () => process.exit(1));
|
||||
|
||||
/* The session dies with its audience: no browser poll for BROWSER_GONE_MS,
|
||||
or the hard ceiling, whichever lands first. */
|
||||
const reaper = setInterval(() => {
|
||||
const quiet = Date.now() - lastBrowserSeen;
|
||||
if (quiet > BROWSER_GONE_MS || (!adopted && quiet > ADOPT_GRACE_MS)) shutdown();
|
||||
}, 15_000);
|
||||
const ceiling = setTimeout(shutdown, timeoutMinutes * 60_000);
|
||||
|
||||
async function shutdown() {
|
||||
clearInterval(reaper);
|
||||
clearTimeout(ceiling);
|
||||
wakeParkedPolls();
|
||||
/* Only if it is still ours. A session that outlived its tab can be shutting
|
||||
down at the moment a newer one writes the same path, and taking the file
|
||||
with it would leave the live session undiscoverable. */
|
||||
const recorded = await readJsonSoft(sessionPath);
|
||||
if (!recorded || recorded.pid === process.pid) {
|
||||
await rm(sessionPath, { force: true }).catch(() => {});
|
||||
}
|
||||
server.close(() => process.exit(0));
|
||||
server.closeAllConnections?.();
|
||||
setTimeout(() => process.exit(0), 1_000).unref();
|
||||
}
|
||||
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
@@ -1,584 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/** Browser questionnaire server (self-contained, zero dependencies).
|
||||
* Serves picker files and cues, writes one JSON submission, then exits.
|
||||
* Usage: node <scripts_path>/picker-server.mjs [--port 8500]
|
||||
* [--cues-dir .impeccable/visual-cues] [--timeout 60]
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { copyFile, readFile, mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SEEDS } from './palette.mjs';
|
||||
import {
|
||||
clearDraft,
|
||||
fontRelativePath,
|
||||
migrate,
|
||||
paths,
|
||||
pidAlive,
|
||||
readAnswers,
|
||||
readDraft,
|
||||
readJsonSoft,
|
||||
writeDraft,
|
||||
writeJsonAtomic,
|
||||
} from './design-context/store.mjs';
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pickerDir = path.join(scriptDir, 'picker');
|
||||
const store = paths(process.cwd());
|
||||
const answersPath = store.answersJson;
|
||||
const fontsDir = store.fontsDir;
|
||||
const brandAssetsDir = store.assetsDir;
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
|
||||
const BRAND_ASSET_EXTENSIONS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif'];
|
||||
const MIME = new Map([
|
||||
['.html', 'text/html; charset=utf-8'],
|
||||
['.css', 'text/css; charset=utf-8'],
|
||||
['.js', 'text/javascript; charset=utf-8'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.webp', 'image/webp'],
|
||||
['.gif', 'image/gif'],
|
||||
['.png', 'image/png'],
|
||||
['.svg', 'image/svg+xml'],
|
||||
['.json', 'application/json; charset=utf-8'],
|
||||
['.woff2', 'font/woff2'],
|
||||
['.woff', 'font/woff'],
|
||||
['.ttf', 'font/ttf'],
|
||||
['.otf', 'font/otf'],
|
||||
]);
|
||||
function printHelp() {
|
||||
console.log(`Usage: node picker-server.mjs [options]
|
||||
|
||||
Serve the Impeccable design picker and wait for one form submission.
|
||||
|
||||
Options:
|
||||
--port PORT Scan for an open port from PORT (default: 8500)
|
||||
--cues-dir PATH Visual cues directory (default: .impeccable/visual-cues)
|
||||
--timeout MINUTES Exit 2 if nothing submits (default: 60)
|
||||
--fresh Start blank, ignoring any previous answers or draft
|
||||
--doc Reopen the design context document; no questionnaire
|
||||
--help Show this help
|
||||
|
||||
Output:
|
||||
PICKER_URL URL Printed when the server is ready
|
||||
ANSWERS PATH Printed after answers.json is written
|
||||
|
||||
Also served, for the design context document the questionnaire reveals:
|
||||
/context.json The chat half of the interview, from the design-context store
|
||||
/cue.png The chosen cue image, copied into the store at submit
|
||||
|
||||
See reference/visual-cues.md for the canonical agent flow.`);
|
||||
}
|
||||
|
||||
function readOption(args, index) {
|
||||
const arg = args[index];
|
||||
const equals = arg.indexOf('=');
|
||||
if (equals !== -1) return { value: arg.slice(equals + 1), next: index };
|
||||
if (!args[index + 1] || args[index + 1].startsWith('--')) {
|
||||
throw new Error(`${arg} requires a value`);
|
||||
}
|
||||
return { value: args[index + 1], next: index + 1 };
|
||||
}
|
||||
function parseArgs(args) {
|
||||
const options = {
|
||||
port: 8500,
|
||||
cuesDir: path.resolve(process.cwd(), '.impeccable/visual-cues'),
|
||||
timeoutMinutes: 60,
|
||||
fresh: false,
|
||||
doc: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--help' || arg === '-h') return { help: true };
|
||||
/* Value-less flags are read before the guard below, which would reject
|
||||
them, and before readOption, which demands a value for every flag. */
|
||||
if (arg === '--fresh') { options.fresh = true; continue; }
|
||||
if (arg === '--doc') { options.doc = true; continue; }
|
||||
if (!arg.startsWith('--port') && !arg.startsWith('--cues-dir') && !arg.startsWith('--timeout')) throw new Error(`Unknown option: ${arg}`);
|
||||
|
||||
const { value, next } = readOption(args, index);
|
||||
index = next;
|
||||
if (arg.startsWith('--port')) options.port = Number(value);
|
||||
if (arg.startsWith('--cues-dir')) options.cuesDir = path.resolve(process.cwd(), value);
|
||||
if (arg.startsWith('--timeout')) options.timeoutMinutes = Number(value);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error('--port must be an integer from 1 to 65535');
|
||||
if (!Number.isFinite(options.timeoutMinutes) || options.timeoutMinutes <= 0) throw new Error('--timeout must be a positive number of minutes');
|
||||
return options;
|
||||
}
|
||||
async function findOpenPort(start = 8500) {
|
||||
if (start > 65535) throw new Error('No open picker port found');
|
||||
return new Promise((resolve) => {
|
||||
const probe = net.createServer();
|
||||
probe.listen(start, '127.0.0.1', () => {
|
||||
const port = probe.address().port;
|
||||
probe.close(() => resolve(port));
|
||||
});
|
||||
probe.on('error', () => resolve(findOpenPort(start + 1)));
|
||||
});
|
||||
}
|
||||
function sendJson(response, statusCode, body) {
|
||||
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function httpError(statusCode, message) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
function decodeRequestPath(rawUrl = '/') {
|
||||
let decoded = rawUrl.split('?')[0];
|
||||
try {
|
||||
for (let pass = 0; pass < 3; pass += 1) {
|
||||
const next = decodeURIComponent(decoded);
|
||||
if (next === decoded) break;
|
||||
decoded = next;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
decoded = decoded.replaceAll('\\', '/');
|
||||
if (decoded.includes('\0') || decoded.split('/').includes('..')) return null;
|
||||
return decoded.startsWith('/') ? decoded : `/${decoded}`;
|
||||
}
|
||||
|
||||
function containedPath(baseDir, relativePath) {
|
||||
const candidate = path.resolve(baseDir, relativePath);
|
||||
const relative = path.relative(baseDir, candidate);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function serveFile(response, baseDir, relativePath, allowedExtensions = MIME.keys()) {
|
||||
const filePath = containedPath(baseDir, relativePath);
|
||||
const extension = path.extname(relativePath).toLowerCase();
|
||||
if (!filePath || ![...allowedExtensions].includes(extension) || !MIME.has(extension)) {
|
||||
response.removeHeader('Cache-Control');
|
||||
sendJson(response, 404, { error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await stat(filePath);
|
||||
if (!info.isFile()) throw new Error('Not a file');
|
||||
const body = await readFile(filePath);
|
||||
response.writeHead(200, {
|
||||
'Content-Type': MIME.get(extension),
|
||||
'Content-Length': body.length,
|
||||
});
|
||||
response.end(body);
|
||||
} catch {
|
||||
response.removeHeader('Cache-Control');
|
||||
sendJson(response, 404, { error: 'Not found' });
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonBody(request) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) throw httpError(413, 'Request body exceeds 1 MB');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
} catch {
|
||||
throw httpError(400, 'Body must be valid JSON');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw httpError(400, 'Body must be a JSON object');
|
||||
return value;
|
||||
}
|
||||
|
||||
let options;
|
||||
try {
|
||||
options = parseArgs(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/* A project interviewed by an older release keeps its answers, assets, and
|
||||
uploaded faces under the pre-store layout. Bring them across before serving. */
|
||||
await migrate(process.cwd());
|
||||
|
||||
const port = await findOpenPort(options.port);
|
||||
let completed = false;
|
||||
let timeout;
|
||||
let docWatch;
|
||||
/* In document mode the run already happened: this process serves the document
|
||||
built from it, and the edit session is what it waits on. */
|
||||
let docSession = null;
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
void handleRequest(request, response).catch((error) => {
|
||||
if (!response.headersSent) sendJson(response, error.statusCode || 500, { error: error.message });
|
||||
else response.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
async function handleRequest(request, response) {
|
||||
const requestPath = decodeRequestPath(request.url);
|
||||
if (!requestPath) {
|
||||
sendJson(response, 400, { error: 'Invalid path' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && requestPath === '/submit') {
|
||||
/* Document mode is showing a run that already finished; there is nothing
|
||||
left to submit, and writing one would overwrite the answers it renders. */
|
||||
if (options.doc) {
|
||||
sendJson(response, 409, { error: 'The document is open; there is nothing to submit' });
|
||||
return;
|
||||
}
|
||||
if (completed) {
|
||||
sendJson(response, 409, { error: 'Submission already received' });
|
||||
return;
|
||||
}
|
||||
const answers = await readJsonBody(request);
|
||||
await writeJsonAtomic(answersPath, answers);
|
||||
await copyChosenCue(answers);
|
||||
/* The run is on the record now, so the half-finished copy of it goes. */
|
||||
await clearDraft();
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
/* The document the review tab is about to reveal stays editable through a
|
||||
detached sibling: it owns the edit endpoints on its own port, so this
|
||||
process can still exit as the agent's completion signal. The tab learns
|
||||
where to reach it from this response; the agent learns from
|
||||
runtime/session.json, which the sibling writes at boot. */
|
||||
const doc = await spawnDocSession();
|
||||
/* The tab fires its first asset requests the moment this response lands,
|
||||
and an img that reaches a forked session still booting fails once and
|
||||
never retries. The session writes its record only after listen succeeds,
|
||||
so the record on disk is readiness itself; no HTTP probe, which would
|
||||
also mark the session adopted before any tab has seen it. */
|
||||
if (doc) await waitForSessionRecord(5000, doc.port);
|
||||
response.once('finish', () => {
|
||||
console.log(`ANSWERS ${answersPath}`);
|
||||
server.close(() => process.exit(0));
|
||||
server.closeAllConnections?.();
|
||||
});
|
||||
sendJson(response, 200, { ok: true, doc });
|
||||
return;
|
||||
}
|
||||
|
||||
/* The questionnaire posts its whole form after every screen change, so a run
|
||||
the visitor walks away from resumes where they left it instead of starting
|
||||
over. The submission supersedes the draft and removes it. */
|
||||
if (request.method === 'POST' && requestPath === '/autosave') {
|
||||
if (completed) {
|
||||
sendJson(response, 409, { error: 'Submission already received' });
|
||||
return;
|
||||
}
|
||||
await writeDraft(await readJsonBody(request));
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Uploaded faces are stored, not parsed: the questionnaire defers validation
|
||||
// to the end, so the server only needs to put the bytes where the agent can
|
||||
// reach them and hand back the path the answers will carry.
|
||||
if (request.method === 'POST' && requestPath === '/font-upload') {
|
||||
const name = path.basename(request.headers['x-font-filename'] || '');
|
||||
if (!name || !FONT_EXTENSIONS.has(path.extname(name).toLowerCase())) {
|
||||
sendJson(response, 400, { error: 'Expected a .woff2, .woff, .ttf, or .otf filename' });
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) throw httpError(413, 'Font exceeds 1 MB');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
await mkdir(fontsDir, { recursive: true });
|
||||
await writeFile(path.join(fontsDir, name), Buffer.concat(chunks));
|
||||
sendJson(response, 200, { path: fontRelativePath(name) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
sendJson(response, 405, { error: 'Method not allowed' });
|
||||
return;
|
||||
}
|
||||
/* One fetch tells the client how to start: which surface it is serving, and
|
||||
the answers to restore, if any. Never cached, because the draft moves
|
||||
while the questionnaire is open and a stale copy would restore a run the
|
||||
visitor has already moved past. */
|
||||
if (requestPath === '/boot.json') {
|
||||
const { prior, priorSource } = await resolvePrior();
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
sendJson(response, 200, {
|
||||
mode: options.doc ? 'doc' : 'questionnaire',
|
||||
prior,
|
||||
priorSource,
|
||||
/* Present only where the document is live for edits. Absent leaves it
|
||||
rendering read-only, which is the honest state when no session took. */
|
||||
doc: docSession ? { base: `http://127.0.0.1:${docSession.port}`, token: docSession.token } : null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (requestPath === '/cues.json') {
|
||||
await serveFile(response, options.cuesDir, 'cues.json', ['.json']);
|
||||
return;
|
||||
}
|
||||
/* The chat half of the interview, and the chosen cue, both live in the store
|
||||
rather than the generation workspace. The document reads them after this
|
||||
process exits, so they carry the same cache rule the cue images do. */
|
||||
if (requestPath === '/context.json') {
|
||||
response.setHeader('Cache-Control', 'max-age=86400');
|
||||
await serveFile(response, store.storeDir, 'context.json', ['.json']);
|
||||
return;
|
||||
}
|
||||
if (requestPath === '/cue.png') {
|
||||
response.setHeader('Cache-Control', 'max-age=86400');
|
||||
await serveFile(response, store.storeDir, 'cue.png', ['.png']);
|
||||
return;
|
||||
}
|
||||
if (requestPath === '/fonts.json') {
|
||||
await serveFile(response, options.cuesDir, 'fonts.json', ['.json']);
|
||||
return;
|
||||
}
|
||||
if (requestPath === '/palettes.json') {
|
||||
sendJson(response, 200, {
|
||||
seeds: SEEDS.map(({ id, oklch, mood }) => ({ id, oklch, mood })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (requestPath.startsWith('/cues/')) {
|
||||
const cueName = requestPath.slice('/cues/'.length);
|
||||
if (!cueName || cueName.includes('/')) {
|
||||
sendJson(response, 404, { error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
// Cue images are re-requested by the design context document after this
|
||||
// process has exited (article content only enters the live DOM after
|
||||
// submit), so they must be servable from the browser's cache.
|
||||
response.setHeader('Cache-Control', 'max-age=86400');
|
||||
await serveFile(response, options.cuesDir, cueName, ['.png']);
|
||||
return;
|
||||
}
|
||||
/* Brand-asset files the agent staged from the chat interview (logos, mood
|
||||
boards, reference images), displayed by the design context document.
|
||||
Read-only, one directory, filenames only. The /assets/ prefix is taken
|
||||
by the picker's own static files, hence the distinct name. */
|
||||
if (requestPath.startsWith('/brand-assets/')) {
|
||||
const assetName = requestPath.slice('/brand-assets/'.length);
|
||||
if (!assetName || assetName.includes('/')) {
|
||||
sendJson(response, 404, { error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
response.setHeader('Cache-Control', 'max-age=86400');
|
||||
await serveFile(response, brandAssetsDir, assetName, BRAND_ASSET_EXTENSIONS);
|
||||
return;
|
||||
}
|
||||
|
||||
// Uploaded faces are read back so the specimen can render in them.
|
||||
if (requestPath.startsWith('/fonts/')) {
|
||||
const fontName = requestPath.slice('/fonts/'.length);
|
||||
if (!fontName || fontName.includes('/')) {
|
||||
sendJson(response, 404, { error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
await serveFile(response, fontsDir, fontName, [...FONT_EXTENSIONS]);
|
||||
return;
|
||||
}
|
||||
|
||||
const assetPath = requestPath === '/' ? 'index.html' : requestPath.slice(1);
|
||||
await serveFile(response, pickerDir, assetPath);
|
||||
}
|
||||
|
||||
/* An unfinished run outranks a finished one: the draft is where the visitor
|
||||
actually is, the submission is where they last were. --fresh declines both. */
|
||||
async function resolvePrior() {
|
||||
if (options.fresh) return { prior: null, priorSource: null };
|
||||
const draft = await readDraft();
|
||||
if (draft) return { prior: draft, priorSource: 'draft' };
|
||||
const answers = await readAnswers();
|
||||
if (answers) return { prior: answers, priorSource: 'submitted' };
|
||||
return { prior: null, priorSource: null };
|
||||
}
|
||||
|
||||
/* The document renders the chosen cue long after this process is gone, and a
|
||||
later reopen has no generation workspace to reach into, so the one picked
|
||||
hero joins the store. A seed or custom palette names no cue: nothing to copy. */
|
||||
async function copyChosenCue(answers) {
|
||||
const slug = typeof answers['palette-source'] === 'string' ? answers['palette-source'] : '';
|
||||
if (!slug || slug !== path.basename(slug)) return;
|
||||
try {
|
||||
await mkdir(path.dirname(store.cuePng), { recursive: true });
|
||||
await copyFile(path.join(options.cuesDir, `${slug}.png`), store.cuePng);
|
||||
} catch {
|
||||
/* Not a cue palette, or the workspace is gone. */
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnDocSession() {
|
||||
try {
|
||||
const docPort = await findOpenPort(port + 1);
|
||||
const docToken = randomUUID();
|
||||
const child = spawn(process.execPath, [
|
||||
path.join(scriptDir, 'picker-doc-session.mjs'),
|
||||
'--port', String(docPort),
|
||||
'--timeout', String(options.timeoutMinutes),
|
||||
], {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env, IMPECCABLE_DOC_TOKEN: docToken },
|
||||
});
|
||||
child.unref();
|
||||
return { base: `http://127.0.0.1:${docPort}`, token: docToken, port: docPort };
|
||||
} catch {
|
||||
/* The document still renders read-only; only the edit loop is lost. */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Document mode: serving the design context document on its own.
|
||||
============================================================ */
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Does the recorded session answer for itself? Also marks it adopted. */
|
||||
async function probeSession(record) {
|
||||
if (!record?.port || !record?.token) return false;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${record.port}/doc/state?token=${encodeURIComponent(record.token)}`,
|
||||
{ signal: AbortSignal.timeout(2000) },
|
||||
);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSessionRecord(deadlineMs, expectPort = 0) {
|
||||
const until = Date.now() + deadlineMs;
|
||||
for (;;) {
|
||||
const record = await readJsonSoft(store.sessionJson);
|
||||
if (record?.port && (!expectPort || record.port === expectPort)) return record;
|
||||
if (Date.now() > until) return null;
|
||||
await sleep(150);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One live session per project.
|
||||
*
|
||||
* A session that answers is rejoined, so reopening a tab closed a minute ago
|
||||
* lands back in the session the agent is already polling, and the probe itself
|
||||
* is what keeps it from being reaped. A dead record is cleared, and a recorded
|
||||
* process that will not answer is stopped and waited out before a replacement
|
||||
* is forked: two sessions would write one discovery file, and the loser's
|
||||
* shutdown would carry off the winner's record.
|
||||
*/
|
||||
async function adoptDocSession() {
|
||||
const recorded = await readJsonSoft(store.sessionJson);
|
||||
if (recorded && pidAlive(recorded.pid)) {
|
||||
if (await probeSession(recorded)) return recorded;
|
||||
try { process.kill(recorded.pid, 'SIGTERM'); } catch { /* already gone */ }
|
||||
for (let waited = 0; waited < 5000 && pidAlive(recorded.pid); waited += 200) await sleep(200);
|
||||
}
|
||||
await rm(store.sessionJson, { force: true }).catch(() => {});
|
||||
|
||||
if (!await spawnDocSession()) return null;
|
||||
/* A session forked before any tab exists has a short window to be adopted or
|
||||
it dies young, and in document mode the tab arrives only once a person
|
||||
opens the URL. This probe is the adoption. */
|
||||
const record = await waitForSessionRecord(5000);
|
||||
if (!record) return null;
|
||||
await probeSession(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/* The session ending is this process's completion signal in document mode.
|
||||
Liveness is the recorded process plus an answer from it, never the presence
|
||||
of the discovery file on its own: a session that crashes leaves the file
|
||||
behind, and a sibling shutting down can carry the file off while the real
|
||||
session is still serving. */
|
||||
function watchDocSession(record) {
|
||||
let misses = 0;
|
||||
const tick = async () => {
|
||||
if (completed) return;
|
||||
if (!pidAlive(record.pid)) return finishDocMode();
|
||||
misses = (await probeSession(record)) ? 0 : misses + 1;
|
||||
if (misses >= 2) return finishDocMode();
|
||||
docWatch = setTimeout(tick, 5000);
|
||||
};
|
||||
docWatch = setTimeout(tick, 5000);
|
||||
}
|
||||
|
||||
function finishDocMode() {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(docWatch);
|
||||
console.log('DOC_SESSION_ENDED');
|
||||
server.close(() => process.exit(0));
|
||||
server.closeAllConnections?.();
|
||||
}
|
||||
|
||||
function stopWithoutSubmission(message) {
|
||||
if (completed) return;
|
||||
clearTimeout(timeout);
|
||||
console.error(message);
|
||||
server.close(() => process.exit(2));
|
||||
server.closeAllConnections?.();
|
||||
}
|
||||
|
||||
/* Document mode needs a run to show and a session to keep it editable, both
|
||||
settled before the URL is printed: an agent that reads PICKER_URL is told
|
||||
the document is ready. */
|
||||
if (options.doc) {
|
||||
if (!await readAnswers()) {
|
||||
console.error('No design interview found. Run /impeccable document to create one.');
|
||||
process.exit(1);
|
||||
}
|
||||
docSession = await adoptDocSession();
|
||||
} else if (!await stat(path.join(options.cuesDir, 'cues.json')).catch(() => null)) {
|
||||
/* The palette screen loads the dealt cues and the built-in seeds together,
|
||||
and neither arrives without this file: refuse rather than serve a broken
|
||||
run. */
|
||||
console.error('No visual cues found. Run /impeccable document --seed to generate them first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`PICKER_URL http://127.0.0.1:${port}`);
|
||||
timeout = setTimeout(
|
||||
() => stopWithoutSubmission(options.doc
|
||||
? 'Design context document closed without an edit session.'
|
||||
: 'Picker timed out without a submission.'),
|
||||
options.timeoutMinutes * 60_000,
|
||||
);
|
||||
/* With no session there is nothing to outlive, so the ceiling is the only
|
||||
limit and the document stays up read-only until it runs out. */
|
||||
if (options.doc && docSession) watchDocSession(docSession);
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
console.error(`Picker server error: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
process.once('SIGINT', () => stopWithoutSubmission('Picker closed without a submission.'));
|
||||
process.once('SIGTERM', () => stopWithoutSubmission('Picker closed without a submission.'));
|
||||
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |