mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
23
Commits
cli-v3.3.0
...
cli-v3.3.1
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 4.0.0
|
||||
version: 4.0.1
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
@@ -47,7 +47,7 @@ Ask blockers once, globally. Missing source path/crops or output directory block
|
||||
3. Treat full-page mock crops as references, not production-resolution source assets. Put a role in `direct` only when the provided source is already a clean, sufficiently large source asset with no semantic text or presentation chrome.
|
||||
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 semantic HTML/CSS/SVG recommendation if raster is wrong.
|
||||
6. Treat every crop as binding reference. In Codex, use the imagegen skill and built-in `image_gen` path by default when generation or editing is needed.
|
||||
6. Treat every crop as binding reference. Use the harness's native image tool by default when generation or editing is needed<codex> (the imagegen skill's built-in `image_gen` path)</codex>; 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.
|
||||
@@ -76,7 +76,7 @@ Remove letterboxing, padding, card borders, rounded clipping, CSS shadows, persp
|
||||
Do not add new objects. Do not change the concept. Do not redesign the composition.
|
||||
```
|
||||
|
||||
For transparent cutouts, use the imagegen skill's built-in-first chroma-key workflow unless the parent explicitly authorizes a true native transparency fallback.
|
||||
For transparent cutouts, use a chroma-key workflow by default<codex> (the imagegen skill's built-in-first path)</codex>: generate on a flat color that cannot appear in the subject, then post-process to alpha; use true native transparency only when the tool supports it or the parent authorizes it.
|
||||
|
||||
## Output Contract
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name = "impeccable_finish_reviewer"
|
||||
description = "Reviews a finished Impeccable build against its direction contract, persistence requirements, and the chosen world's quality bar, returning an ordered list of material fixes."
|
||||
model_reasoning_effort = "high"
|
||||
nickname_candidates = ["Finishing Eye", "Contract Judge", "Ceiling Check"]
|
||||
developer_instructions = '''
|
||||
# Impeccable Finish Reviewer
|
||||
|
||||
You are the finishing reviewer for an Impeccable build: fresh eyes on a done artifact, outside the build thread's attention gravity. You do not edit anything; the parent agent applies your fixes.
|
||||
|
||||
## Input Contract
|
||||
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md and DESIGN.md paths; existing hook or detector findings; the chosen world's QUALITY BAR card paths and approved comp paths when they exist; screenshot path(s) when available. When the harness can view images, open the card, the comp, and the screenshot before judging.
|
||||
|
||||
## Checks, in order
|
||||
|
||||
1. **Persistence.** On a new or replacement world: PRODUCT.md and DESIGN.md exist, and DESIGN.md matches the built world. A missing or mismatched file is the first material fix, ahead of any craft point.
|
||||
2. **Ceiling.** Against the QUALITY BAR card and the approved comp, name the world's native devices the build left unused: frame, depth, lettering treatment, ornament density, motion. Compare commitment and finish, never composition; the card is a bar, not a layout.
|
||||
3. **Contract, promise by promise.** For each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport: what would a visitor describe an hour later, and is it the thesis or a mood?
|
||||
4. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions.
|
||||
|
||||
Do not run a second detector pass; mechanical findings belong to the parent's hooks.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Return exactly four sections: `persistence` (pass/fail with specifics), `ceiling` (the unused native devices, or "reached"), `material_fixes` (ordered, most material first, each one line tied to a check or contract promise, at most eight), and `keep` (one line naming what must not be diluted while fixing). No praise, no summary prose.
|
||||
'''
|
||||
@@ -44,9 +44,11 @@ The script assigns which structure gets built: your top-ranked structure is what
|
||||
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, the notation, publications, identity programs, data graphics, and interfaces it reads daily, not only its physical objects; a nameable abstract system (a school of poster, a documentation standard, a data-graphic tradition) 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; the audience's world is larger than that, so dig until the list spans at least three families.
|
||||
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
|
||||
4. Run `node .agents/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Offer re-roll with an optional one-line steer instead of a ranked menu; a lineup invites the safest card. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Pick the channel by capability, not by habit: can you put a page in front of the user, through an in-app browser or by opening a browser window on their machine outside your harness? If yes, present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll plus steer enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll, then run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, preferring the in-app browser when the harness has one, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. If no, because the session is headless, CI, an eval worker, or a remote shell with no display, skip the page and put the same decision through the structured question tool; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the one or two fused challengers that survived the weighing as named alternates with a one-line case each, plus re-roll with an optional one-line steer. What you never present is a ranked menu of your own grounded candidates; a lineup of those invites the safest card, while dealt challengers carry no such rut. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list also carries the standing exit as its last option.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. When the harness can view images, open the QUALITY BAR board and hero the seed prints for the world you build (when it only reads local images, download the card to a temp file first and view that): they set the craft level the build must reach, the finish, commitment, and art direction of a rendered reference, and never dictate the composition; your surface serves this product.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images for exactly this reason (download to a temp file when the harness only reads local images). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
|
||||
|
||||
Every direction the roll can land on must already be viable: every relationship and claim it visualizes true, a real palette and component family, a distinctive composition with one product-specific experience, workable at full-surface scale within the available assets, tools, and performance budget. A candidate that fails on truth is replaced before the roll, never rescued by it. Truth binds claims, not demonstrations: in greenfield work, author whatever illustrative material the concept needs at full fidelity, label it synthetic wherever a visitor could mistake it for the real thing, and hand the user the list of what to replace with real material. What stays uninventable are commercial and factual claims: prices, customers, benchmarks, endpoints, capabilities the product does not have. Refusing a bold direction because its demonstration data does not exist yet is the timidity reflex wearing honesty's clothes.
|
||||
|
||||
@@ -62,10 +64,12 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su
|
||||
|
||||
## 5. Record the decision
|
||||
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. 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. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, the staging chosen for it, and the seed key the script printed; when the seed dealt stagings, choosing the world does not silently discharge the staging decision, so name the one you commit to. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
|
||||
When a new or replacement world is chosen, DESIGN.md is part of recording the decision, not an aftercare step: write it at the appropriate project or app boundary using [document.md](document.md) before the first build edit lands, in the same working stretch as the direction contract. Record only durable system rules; exact tokens may remain provisional until the first build establishes them, and you update the file when the build settles them. A new world shipped with no DESIGN.md is an incomplete run, exactly as a missing PRODUCT.md is; the finishing review checks the file exists and matches the built world. An ordinary extension does not rewrite DESIGN.md.
|
||||
|
||||
Two observed ways a DESIGN.md rule goes wrong: a prohibition that bans a device the world itself uses natively (arcade lettering lives on its offset shadow), and a token added so a hook finding stops firing. Check every prohibition against the world's own materials; a value earns its place by the world and by legibility, never by silencing a check.
|
||||
|
||||
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
|
||||
|
||||
`node .agents/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
|
||||
@@ -74,7 +78,7 @@ If the work establishes durable strategy for a route or artifact, read its exist
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
Visualize before you build whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports: render the chosen direction as a design-system board and a first-surface mock, correct material drift between mock and intent, then build. Seeing the direction first measurably strengthens the result. [codex.md](codex.md) carries the deep native-generation flow; the mock is a selection aid, not authority.
|
||||
Whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options rendered and put before the user for approval. This step is proven to produce the most compositional and ambitious work.
|
||||
|
||||
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
|
||||
|
||||
@@ -84,8 +88,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie
|
||||
|
||||
- **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. 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; never invent prices, customers, benchmarks, or capabilities.
|
||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, titles, 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, and no section is omittable. When a commercial claim stayed unanswered, ship a clearly marked placeholder value and hand the user the replacement list; a section thinned or dropped for missing truth is the asset gap wearing honesty's clothes. Decoration compensating for missing content, gradients, glass, borders, icon tiles where an authored asset belongs, is the same gap wearing chrome's.
|
||||
- **Generate the imagery the build needs.** When any image generation is available, a harness-native tool or the API fallback context.mjs reports, generating the design's imagery is part of building: heroes, covers, demonstration thumbnails, textures, marks. Gray boxes and stock-styled placeholders are not a fallback while generation is one command away; state the cost once before the first render and batch what the surface needs.
|
||||
- **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 that before a line of code exists. 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.
|
||||
@@ -97,4 +100,4 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
||||
|
||||
Inspect desktop and mobile, critique the render against the user's request, the direction contract, and DESIGN.md, fix material gaps, and re-inspect. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary. After a first implementation of a new world, update DESIGN.md with the exact tokens and behaviors that survived the build.
|
||||
|
||||
When the harness can run a separate agent, this review belongs there, not in the build thread: give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, and existing hook findings. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector.
|
||||
Spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex), and give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, existing hook findings, and the QUALITY BAR card and approved comp paths. This review never runs inside the build thread; a harness with no subagents runs it as its own fresh pass, after stepping fully out of the build context. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Its second check is the ceiling: against the chosen world's QUALITY BAR card and the approved comp, name the native devices the build left unused, frame, depth, lettering, ornament density; floor rigor is very good at disguising an unreached card. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector.
|
||||
|
||||
+8
-8
@@ -1,15 +1,15 @@
|
||||
# Codex: Surface Probes & Asset Production
|
||||
# Visualize: Direction Comps & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) only when the harness has native image generation and a substantial, high-fidelity surface would benefit from seeing the shortlisted concept before code. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
Load this from [new-work.md](new-work.md) whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate the smallest useful probe set
|
||||
## Generate three compositional options
|
||||
|
||||
Generate one to three high-fidelity north-star comps using the native image-generation capability. Base them on the real content and the surface concepts already developed with the user.
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building.
|
||||
|
||||
- When the user shortlisted multiple concepts, show one clear expression of each.
|
||||
- When one concept is already selected, vary only the structural uncertainty that the image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||
- When the user shortlisted multiple concepts, spread the three across them.
|
||||
- When one direction is committed, vary the structural uncertainty an image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||
- Show enough beyond the opening moment to prove the concept can govern the whole requested surface.
|
||||
- Do not generate a palette artifact, ask new atmosphere questions, introduce a different type voice, or invent a new motif. If the committed world cannot support the concept, return to the concept shortlist rather than changing the world.
|
||||
|
||||
@@ -17,7 +17,7 @@ Treat each comp as a direction test, not a screenshot specification. Core UI tex
|
||||
|
||||
## One approval point
|
||||
|
||||
Show the probes together and ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
Show the three together: in the harness when it can display images, otherwise on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero). Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
@@ -33,6 +33,6 @@ Treat the comp as a north star, not something to trace. Do not rasterize core UI
|
||||
|
||||
## Produce only the assets the build needs
|
||||
|
||||
When clean raster ingredients are required and a scoped subagent is available and authorized, use `impeccable_asset_producer`. Give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets with the native image-generation capability in the current thread.
|
||||
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread with whatever generation exists, the native tool or generate-image.mjs.
|
||||
|
||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||
@@ -79,6 +79,13 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_DIR = process.env.IMPECCABLE_CATALOG_DIR || here;
|
||||
const API_BASE = (process.env.IMPECCABLE_API_URL || 'https://impeccable.style/api').replace(/\/$/, '');
|
||||
const API_TIMEOUT_MS = Number(process.env.IMPECCABLE_API_TIMEOUT || 4000);
|
||||
// All API calls in one seed run share a single deadline so an unreachable
|
||||
// network degrades after one timeout total, never one timeout per call.
|
||||
let apiDeadline = null;
|
||||
function apiBudgetMs() {
|
||||
if (apiDeadline === null) apiDeadline = Date.now() + API_TIMEOUT_MS;
|
||||
return Math.max(0, apiDeadline - Date.now());
|
||||
}
|
||||
|
||||
const localStates = new Map();
|
||||
function loadLocal(catalogDir = CATALOG_DIR) {
|
||||
@@ -120,9 +127,15 @@ async function fetchRoll({ scope, key, mode, reroll }) {
|
||||
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
|
||||
if (mode) params.set('mode', mode);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal });
|
||||
// Race the budget explicitly: abort signals do not reliably cancel the
|
||||
// TCP connect phase, so a blackholed route would otherwise stall ~10s.
|
||||
const response = await Promise.race([
|
||||
fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal }),
|
||||
new Promise(resolveTimeout => setTimeout(() => resolveTimeout(null), apiBudgetMs())),
|
||||
]);
|
||||
if (!response) return null;
|
||||
if (!response.ok) return null;
|
||||
const roll = await response.json();
|
||||
if (!Array.isArray(roll.challengers) || roll.challengers.length === 0) return null;
|
||||
@@ -143,7 +156,7 @@ function telemetryDisabled() {
|
||||
export async function pingChosen({ chosenId, key, scope, mode }) {
|
||||
if (telemetryDisabled() || !chosenId) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
await fetch(`${API_BASE}/chosen`, {
|
||||
method: 'POST',
|
||||
@@ -445,7 +458,10 @@ ASSIGNED INDEX: ${buildIndex}
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
No challengers this run: the roll service was unreachable and no local
|
||||
catalog exists. Proceed with the grounded candidates alone; the assignment
|
||||
catalog exists. A sandboxed shell without network egress is the most common
|
||||
cause: if this harness can rerun the command with network access granted,
|
||||
do that once before proceeding. Otherwise proceed with the grounded
|
||||
candidates alone; the assignment
|
||||
above still applies at full strength. Tell the user plainly that this roll
|
||||
ran degraded, with no challengers and no quality-bar boards; do not present
|
||||
the outcome as a full roll. A degraded roll changes the cards, not the
|
||||
@@ -543,4 +559,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
// A raced-out fetch may still hold a socket; exit explicitly so the CLI
|
||||
// never lingers on a dead network path after output is written.
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
|
||||
@@ -1223,6 +1223,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -1541,6 +1545,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadDesignSystemForCwd } from '../design-system.mjs';
|
||||
import { loadDesignSystemForTarget } from '../design-system.mjs';
|
||||
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs';
|
||||
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
|
||||
import { detectHtml } from '../engines/static-html/detect-html.mjs';
|
||||
@@ -27,9 +28,37 @@ function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
// Local filesystem path behind a file:// URL, or null when it can't be mapped.
|
||||
function fileUrlToLocalPath(url) {
|
||||
try {
|
||||
return fileURLToPath(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Advisory findings are detected but never treated as failures: they list in a
|
||||
// separate, visually dimmed section, are excluded from the failure count that
|
||||
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
|
||||
// filter. Every advisory finding carries the flag (stamped by the registry via
|
||||
// findings.mjs).
|
||||
function isAdvisory(finding) {
|
||||
return finding && finding.advisory === true;
|
||||
}
|
||||
|
||||
function partitionAdvisory(findings) {
|
||||
const primary = [];
|
||||
const advisory = [];
|
||||
for (const f of findings) (isAdvisory(f) ? advisory : primary).push(f);
|
||||
return { primary, advisory };
|
||||
}
|
||||
|
||||
// ANSI dim, when stderr is a TTY. Advisory output is chrome, so keep it quiet.
|
||||
function dim(text) {
|
||||
return process.stderr.isTTY ? `\x1b[2m${text}\x1b[0m` : text;
|
||||
}
|
||||
|
||||
function formatFindingsBody(findings) {
|
||||
const grouped = {};
|
||||
for (const f of findings) {
|
||||
if (!grouped[f.file]) grouped[f.file] = [];
|
||||
@@ -44,7 +73,28 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatAdvisorySection(advisory) {
|
||||
if (!advisory || advisory.length === 0) return '';
|
||||
const lines = [`\n${dim('── Advisory (not counted as failures) ──')}`];
|
||||
for (const line of formatFindingsBody(advisory)) lines.push(dim(line));
|
||||
lines.push(dim(`\n${advisory.length} advisory note${advisory.length === 1 ? '' : 's'}. Suppress with --no-advisory.`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Text/JSON formatter. `findings` is the full set; advisory items are separated
|
||||
// out into their own section and excluded from the failure summary count. JSON
|
||||
// output keeps every finding (each advisory one flagged) in a single array.
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
const { primary, advisory } = partitionAdvisory(findings);
|
||||
const out = [...formatFindingsBody(primary)];
|
||||
out.push(`\n${formatFindingSummary(primary.length)}`);
|
||||
const advisorySection = formatAdvisorySection(advisory);
|
||||
if (advisorySection) out.push(advisorySection);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -52,7 +102,11 @@ function formatFindings(findings, jsonMode) {
|
||||
// Stdin handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleStdin(options = {}) {
|
||||
// `optionsFor` maps a local path to scan options carrying that path's own
|
||||
// project design system (or base options when null). Falls back to a plain
|
||||
// object so direct/legacy callers still work.
|
||||
async function handleStdin(optionsFor = () => ({})) {
|
||||
const resolve = typeof optionsFor === 'function' ? optionsFor : () => optionsFor;
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const input = Buffer.concat(chunks).toString('utf-8');
|
||||
@@ -60,11 +114,12 @@ async function handleStdin(options = {}) {
|
||||
const parsed = JSON.parse(input);
|
||||
const fp = parsed?.tool_input?.file_path;
|
||||
if (fp && fs.existsSync(fp)) {
|
||||
const options = resolve(fp);
|
||||
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
|
||||
? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options);
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return detectText(input, '<stdin>', options);
|
||||
return detectText(input, '<stdin>', resolve(null));
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +155,14 @@ Options:
|
||||
ignore comments, or DESIGN.md
|
||||
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
|
||||
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
|
||||
--no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)
|
||||
--help Show this help message
|
||||
|
||||
Advisory findings:
|
||||
Some rules are advisory: detected and listed in a separate section, but never
|
||||
counted as failures and never changing the exit code. They stay out of the
|
||||
failure count so they never block automation. --no-advisory hides them.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -139,6 +200,7 @@ async function detectCli() {
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
const noAdvisory = args.includes('--no-advisory');
|
||||
// --fast (regex-only) is deprecated: since the jsdom removal, the static
|
||||
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
|
||||
// only loses coverage for no real speed win. Accept the flag for back-compat
|
||||
@@ -199,14 +261,23 @@ async function detectCli() {
|
||||
process.exit(1);
|
||||
}
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
|
||||
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
|
||||
// apply by default. `--no-config` (raw scan) and the dedicated
|
||||
// `--no-inline-ignores` both turn them off.
|
||||
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
|
||||
const scanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
if (viewport) scanOptions.viewport = viewport;
|
||||
const baseScanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (viewport) baseScanOptions.viewport = viewport;
|
||||
// DESIGN.md must resolve from EACH scan target's own project root, not from
|
||||
// process.cwd(): scanning project B's files from inside project A applied A's
|
||||
// design rules (cross-project contamination). Resolve per target, memoized by
|
||||
// resolved project root so a multi-file scan pays the read once per project.
|
||||
// A target with no project marker above it gets no design system (never cwd's).
|
||||
const designSystemCache = new Map();
|
||||
const scanOptionsFor = (localPath) => {
|
||||
if (!designSystemEnabled || !localPath) return baseScanOptions;
|
||||
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
||||
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
|
||||
};
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -214,7 +285,7 @@ async function detectCli() {
|
||||
let allFindings = [];
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptions);
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
} else {
|
||||
const paths = targets.length > 0 ? targets : [process.cwd()];
|
||||
// file:// URLs get the same Puppeteer-rendered pass as http(s) — the
|
||||
@@ -228,10 +299,17 @@ async function detectCli() {
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (urlRe.test(target)) {
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
// process.cwd()'s.
|
||||
const urlOptions = /^file:/i.test(target)
|
||||
? scanOptionsFor(fileUrlToLocalPath(target))
|
||||
: baseScanOptions;
|
||||
try {
|
||||
const scanner = browserDetector
|
||||
? (url) => browserDetector.detectUrl(url, scanOptions)
|
||||
: (url) => detectUrl(url, scanOptions);
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
@@ -297,11 +375,14 @@ async function detectCli() {
|
||||
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
let fileFindings;
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
fileFindings = await detectHtml(file, scanOptions);
|
||||
fileFindings = await detectHtml(file, fileOptions);
|
||||
} else {
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions);
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, fileOptions);
|
||||
}
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
@@ -316,10 +397,11 @@ async function detectCli() {
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
allFindings.push(...await detectHtml(resolved, scanOptions));
|
||||
allFindings.push(...await detectHtml(resolved, fileOptions));
|
||||
} else {
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions));
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,12 +412,24 @@ async function detectCli() {
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
// --no-advisory drops advisory findings before any output or exit-code math.
|
||||
if (noAdvisory) allFindings = allFindings.filter((f) => !isAdvisory(f));
|
||||
|
||||
// The exit code and failure count reflect non-advisory findings only. An
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else if (quietMode) {
|
||||
process.stderr.write(formatFindingSummary(primary.length) + '\n');
|
||||
if (advisory.length > 0) {
|
||||
process.stderr.write(dim(`${advisory.length} advisory note${advisory.length === 1 ? '' : 's'} (not counted).`) + '\n');
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { finding } from './findings.mjs';
|
||||
@@ -7,6 +8,11 @@ import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs';
|
||||
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// Files/dirs whose presence marks a directory as a project root. Mirrors the
|
||||
// walk-up semantics of skill/scripts/context.mjs (`resolveProject`), which the
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
@@ -469,6 +475,62 @@ function loadDesignSystemForCwd(cwd = process.cwd()) {
|
||||
});
|
||||
}
|
||||
|
||||
// Directory to begin the project-root walk from, given a scan target that may
|
||||
// be a file or a directory (and may not exist yet).
|
||||
function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
return fs.statSync(abs).isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
// Nonexistent path: treat an extension-bearing leaf as a file.
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
// Returns { dir, hasDesign } for the stopping directory, or null when the walk
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the design system that governs a specific scan target, by walking up
|
||||
// from the target's own location — never process.cwd(). Scanning project B's
|
||||
// files from inside project A applies B's DESIGN.md (or none), not A's.
|
||||
//
|
||||
// Pass a `cache` Map to memoize by resolved design root across a multi-file
|
||||
// scan; a target with no design root above it resolves to null.
|
||||
export function loadDesignSystemForTarget(targetPath, { cache, cwd = process.cwd() } = {}) {
|
||||
const startDir = designSystemStartDir(targetPath, cwd);
|
||||
const found = findDesignRoot(startDir);
|
||||
const key = found ? `root:${found.dir}` : '\0none';
|
||||
if (cache && cache.has(key)) return cache.get(key);
|
||||
const loaded = found?.hasDesign ? loadDesignSystemForCwd(found.dir) : null;
|
||||
if (cache) cache.set(key, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function isAllowedFont(font, designSystem) {
|
||||
if (!font || GENERIC_FONTS.has(font)) return true;
|
||||
if (!designSystem?.hasFonts) return true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS, EM_DASH_FLOOR, EM_DASH_CHARS_PER_DASH } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
@@ -16,6 +16,7 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
|
||||
const hasBorderRadius = (line) => /border-radius/i.test(line);
|
||||
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
|
||||
|
||||
|
||||
/** Strip HTML to plain text — drops script/style/comments/tags so
|
||||
* content-text analyzers don't false-positive on code or CSS. */
|
||||
function stripHtmlToText(html) {
|
||||
@@ -306,15 +307,34 @@ const REGEX_ANALYZERS = [
|
||||
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
||||
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
|
||||
},
|
||||
// Em-dash overuse: 5+ em-dashes or "--" in body text content
|
||||
// (occasional em-dash use in prose is fine; the pattern fires only
|
||||
// when count crosses into AI-cadence territory).
|
||||
// Em-dash overuse (ADVISORY): the AI cadence tell is em-dash *saturation*,
|
||||
// not the occasional dash. Humans use em-dashes legitimately, so this rule is
|
||||
// advisory (surfaced separately, never a failure, hook-skipped by default) and
|
||||
// its threshold is deliberately conservative. Two gates must both hold:
|
||||
// 1. Absolute floor of EM_DASH_FLOOR (8) dashes — a page with a handful
|
||||
// never fires, no matter how short.
|
||||
// 2. Density: at least one dash per EM_DASH_CHARS_PER_DASH (500) characters
|
||||
// of body text, so a long article that uses eight across several thousand
|
||||
// words is left alone while a short, dash-per-clause landing page is not.
|
||||
// Raised from the old flat 5-dash floor, which fired on ordinary long prose.
|
||||
//
|
||||
// stripHtmlToText drops tags but leaves character-entity escapes intact, so
|
||||
// a model that writes `—`, `—`, or `—` renders an em-dash
|
||||
// the counter never saw. Decode the em-dash entities (named, zero-padded
|
||||
// decimal, upper/lower hex) to the literal glyph first. En-dash entities are
|
||||
// deliberately left alone: the rule counts em-dashes, and the literal `–`
|
||||
// was never counted either.
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
const text = stripHtmlToText(content)
|
||||
.replace(/—|�*8212;|�*2014;/gi, '—');
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(text) !== null) count++;
|
||||
if (count < 5) return [];
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
// Saturation gate: dashes must be dense in the prose, not sprinkled through
|
||||
// a long document. textLength <= count * chars-per-dash means the density is
|
||||
// at or above the threshold.
|
||||
if (text.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
|
||||
},
|
||||
// Marketing buzzwords: SaaS phrase list
|
||||
|
||||
@@ -6,7 +6,13 @@ function getAP(id) {
|
||||
|
||||
function finding(id, filePath, snippet, line = 0) {
|
||||
const ap = getAP(id);
|
||||
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
// Advisory findings are detected but reported separately and never counted as
|
||||
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
|
||||
// can partition without a registry lookup. Only stamped when true to keep the
|
||||
// finding shape stable for the vast majority of rules.
|
||||
if (ap.advisory === true) base.advisory = true;
|
||||
return base;
|
||||
}
|
||||
|
||||
export { getAP, finding };
|
||||
|
||||
@@ -213,9 +213,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -405,6 +410,14 @@ const ANTIPATTERNS = [
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
},
|
||||
{
|
||||
id: 'undersized-ui-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Undersized functional text',
|
||||
description:
|
||||
'Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.',
|
||||
},
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
@@ -556,6 +569,18 @@ function getAntipattern(id) {
|
||||
return ANTIPATTERNS.find(rule => rule.id === id);
|
||||
}
|
||||
|
||||
// Advisory rules are detected and reported, but never treated as failures:
|
||||
// the CLI lists them under a separate "Advisory" section, they do not affect
|
||||
// exit codes or the failure count, and the design hook skips them by default.
|
||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
||||
const ADVISORY_RULE_IDS = new Set(
|
||||
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
|
||||
);
|
||||
|
||||
function isAdvisoryRule(id) {
|
||||
return ADVISORY_RULE_IDS.has(id);
|
||||
}
|
||||
|
||||
function getRulesForCategory(category) {
|
||||
return ANTIPATTERNS.filter(rule => rule.category === category);
|
||||
}
|
||||
@@ -585,8 +610,10 @@ export {
|
||||
ANTIPATTERNS,
|
||||
RULE_SCOPES,
|
||||
RULE_ENGINE_SUPPORT,
|
||||
ADVISORY_RULE_IDS,
|
||||
getAntipattern,
|
||||
getRulesForCategory,
|
||||
getRuleEngineSupport,
|
||||
isAdvisoryRule,
|
||||
filterByScopes,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
BORDER_SAFE_TAGS,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
EM_DASH_FLOOR,
|
||||
GENERIC_FONTS,
|
||||
KNOWN_SERIF_FONTS,
|
||||
OVERUSED_FONTS,
|
||||
@@ -2594,6 +2596,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -2821,6 +2850,27 @@ function textDescendantsFlushSides(el, rect) {
|
||||
return flush;
|
||||
}
|
||||
|
||||
// Screen-reader-only ("visually hidden") text is exempt from the tiny-text
|
||||
// floors: it is never rendered, so its size is irrelevant. Detect the two
|
||||
// standard idioms — a known sr-only class on the element or an ancestor, and
|
||||
// the clip / 1px-box pattern. Works in both jsdom (declared styles) and the
|
||||
// browser (computed styles).
|
||||
const SR_ONLY_SELECTOR = '.sr-only, .visually-hidden, .visuallyhidden, .screen-reader, .screen-reader-only, .screenreader, .a11y-hidden, .hidden-visually, [class*="sr-only" i], [class*="visually-hidden" i], [class*="visuallyhidden" i], [class*="screen-reader" i], [class*="screenreader" i]';
|
||||
function isVisuallyHidden(el, style) {
|
||||
if ((el.matches && el.matches(SR_ONLY_SELECTOR)) || (el.closest && el.closest(SR_ONLY_SELECTOR))) return true;
|
||||
const pos = style.position || '';
|
||||
if (pos === 'absolute' || pos === 'fixed') {
|
||||
const clip = style.clip || '';
|
||||
const clipPath = style.clipPath || style.webkitClipPath || style['clip-path'] || '';
|
||||
if (/rect\(\s*0/.test(clip) || /inset\(\s*(?:50%|99|100%)/.test(clipPath)) return true;
|
||||
const w = parseFloat(style.width);
|
||||
const h = parseFloat(style.height);
|
||||
const overflow = style.overflow || '';
|
||||
if ((w === 1 || h === 1) && (overflow === 'hidden' || overflow === 'clip')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
||||
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
||||
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
||||
@@ -3105,6 +3155,60 @@ function checkQuality(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Undersized functional / UI text ---
|
||||
// Complements `tiny-text` above, which owns long body copy and deliberately
|
||||
// EXEMPTS the UI furniture layer (nav, footer, links, buttons, labels,
|
||||
// uppercase micro-labels). This rule targets exactly that blind spot: the
|
||||
// interactive and short content-bearing text — nav items, buttons, labels,
|
||||
// table cells, meta rows, timecodes — shipped below an 11px floor.
|
||||
//
|
||||
// The live failure it closes: a build shipped its entire furniture layer at
|
||||
// 8px, and the design hook waved it through because 8px had been added to
|
||||
// the DESIGN.md size ramp. Being on the ramp is a token argument, not a
|
||||
// legibility one, so this rule ignores the design system entirely — a value
|
||||
// on the ramp is still flagged.
|
||||
//
|
||||
// Floors: 11px for anything functional. The floor holds inside a footer;
|
||||
// only NON-interactive legal smallprint gets the softer 10px floor. Exempts
|
||||
// sup/sub, visually-hidden (sr-only) text, and code/terminal contexts.
|
||||
// Uppercase letterspaced micro-labels are still functional — not exempt.
|
||||
{
|
||||
const directText = [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
.map(n => n.textContent || '')
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const dtLen = directText.length;
|
||||
const UI_SKIP_TAGS = new Set(['sub', 'sup', 'script', 'style', 'title', 'option']);
|
||||
const notRendered = style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse';
|
||||
// jsdom resolves the parent chain in resolveFontSizePx, so em/rem/%-sized
|
||||
// text that computes at or above the floor never reaches here. The browser
|
||||
// adapter additionally catches values only resolvable with real layout
|
||||
// (e.g. viewport-relative units, cascade winners set in linked sheets).
|
||||
if (fontSize > 0 && fontSize < 11 && dtLen >= 2 && !UI_SKIP_TAGS.has(tag) && !notRendered) {
|
||||
const EXEMPT_CONTEXT = 'pre, code, kbd, samp, var, svg, [aria-hidden="true"], [class*="terminal" i], [class*="console" i], [class*="code" i], [class*="mock" i], [class*="editor" i], [class*="syntax" i], [class*="diff" i]';
|
||||
const isExemptContext = (el.matches && el.matches(EXEMPT_CONTEXT)) || (el.closest && el.closest(EXEMPT_CONTEXT));
|
||||
if (!isExemptContext && !isVisuallyHidden(el, style)) {
|
||||
const INTERACTIVE = 'a[href], button, summary, label, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="treeitem"], [tabindex]';
|
||||
const FURNITURE = 'nav, [role="navigation"], td, th, [role="gridcell"], [role="cell"], caption, figcaption, dt, dd, footer, [class*="meta" i], [class*="label" i], [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="kicker" i], [class*="eyebrow" i], [class*="breadcrumb" i], [class*="timestamp" i], [class*="category" i], [class*="caption" i], [class*="nav" i]';
|
||||
const SMALLPRINT = 'small, footer, [class*="legal" i], [class*="copyright" i], [class*="fineprint" i], [class*="fine-print" i], [class*="smallprint" i], [class*="small-print" i], [class*="disclaimer" i], [class*="disclosure" i], [class*="footnote" i]';
|
||||
const isInteractive = (el.matches && el.matches(INTERACTIVE)) || (el.closest && el.closest(INTERACTIVE));
|
||||
const isFurniture = (el.matches && el.matches(FURNITURE)) || (el.closest && el.closest(FURNITURE));
|
||||
const isSmallprint = (el.matches && el.matches(SMALLPRINT)) || (el.closest && el.closest(SMALLPRINT));
|
||||
const floor = (!isInteractive && isSmallprint) ? 10 : 11;
|
||||
// Fire on functional text only: interactive, structural furniture, or
|
||||
// any short (<=20-char) run — the label / meta / timecode shape. Long
|
||||
// non-furniture body copy stays with `tiny-text`, so the two rules
|
||||
// never double-flag the same element.
|
||||
if (fontSize < floor && (isInteractive || isFurniture || dtLen <= 20)) {
|
||||
const excerpt = directText.slice(0, 40);
|
||||
findings.push({ id: 'undersized-ui-text', snippet: `${fontSize}px functional text "${excerpt}" (below ${floor}px floor)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- All-caps body text ---
|
||||
if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
|
||||
if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
||||
@@ -5121,6 +5225,8 @@ export {
|
||||
checkNumberedSectionLabels,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkNumberedSectionLabelsDOM,
|
||||
checkEmDashOveruse,
|
||||
checkEmDashOveruseDOM,
|
||||
isRepeatedTextContainer,
|
||||
collectRepeatedContainerTextFindings,
|
||||
checkRepeatedContainerTextFromDoc,
|
||||
|
||||
@@ -68,6 +68,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -97,5 +106,7 @@ export {
|
||||
GENERIC_FONTS,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
KNOWN_SERIF_FONTS,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
function arg(name, fallback = null) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
@@ -20,6 +21,183 @@ function arg(name, fallback = null) {
|
||||
return v && !v.startsWith('--') ? v : fallback;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake mode (IMPECCABLE_IMAGE_GEN_FAKE=1)
|
||||
//
|
||||
// Deterministic offline stand-in for the OpenAI call: same prompt -> identical
|
||||
// bytes, no network, no key, cost line reads $0.00. Used by the new-work smoke
|
||||
// suite so the concept/serve-question/image chain can run without spend. The
|
||||
// output renders the prompt over a 2-3 color palette hashed from the prompt,
|
||||
// plus a "SYNTHETIC COMP" corner label. SVG carries the readable text; the
|
||||
// raster (.png/.webp/.jpg) fallback carries palette stripes and stows the
|
||||
// prompt + marker in a PNG tEXt chunk so downstream stays a valid image.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FNV-1a 32-bit: tiny, dependency-free, stable across runs and platforms.
|
||||
function hash32(str) {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function hslToRgb(hDeg, s, l) {
|
||||
const h = ((hDeg % 360) + 360) % 360 / 360;
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const hue = (t) => {
|
||||
let tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||
if (tt < 1 / 2) return q;
|
||||
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||
return p;
|
||||
};
|
||||
return [hue(h + 1 / 3), hue(h), hue(h - 1 / 3)].map((c) => Math.round(c * 255));
|
||||
}
|
||||
|
||||
const toHex = ([r, g, b]) =>
|
||||
'#' + [r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// Two or three deterministic swatches derived from the prompt hash. The band
|
||||
// count itself is prompt-derived, so different prompts differ in palette.
|
||||
function palette(prompt) {
|
||||
const h = hash32(prompt);
|
||||
const base = h % 360;
|
||||
const bands = 2 + (h >>> 9) % 2; // 2 or 3
|
||||
const spread = 40 + (h >>> 3) % 120;
|
||||
const out = [];
|
||||
for (let i = 0; i < bands; i++) {
|
||||
const hue = base + i * spread;
|
||||
const light = 0.32 + ((h >>> (i * 5)) % 40) / 100; // 0.32 - 0.71
|
||||
out.push(hslToRgb(hue, 0.55, light));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function svgFake(prompt, [w, h]) {
|
||||
const colors = palette(prompt).map(toHex);
|
||||
const stops = colors
|
||||
.map((c, i) => `<stop offset="${Math.round((i / (colors.length - 1)) * 100)}%" stop-color="${c}"/>`)
|
||||
.join('');
|
||||
// Greedy word wrap tuned to the canvas width so the prompt stays legible.
|
||||
const perLine = Math.max(12, Math.floor(w / 26));
|
||||
const words = String(prompt).replace(/\s+/g, ' ').trim().split(' ');
|
||||
const lines = [];
|
||||
let cur = '';
|
||||
for (const word of words) {
|
||||
if ((cur + ' ' + word).trim().length > perLine) {
|
||||
if (cur) lines.push(cur);
|
||||
cur = word;
|
||||
} else {
|
||||
cur = (cur + ' ' + word).trim();
|
||||
}
|
||||
if (lines.length >= 10) break;
|
||||
}
|
||||
if (cur && lines.length < 11) lines.push(cur);
|
||||
const escape = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||||
const fontSize = Math.round(w / 24);
|
||||
const startY = h / 2 - ((lines.length - 1) * fontSize * 1.3) / 2;
|
||||
const text = lines
|
||||
.map((line, i) => `<text x="${w / 2}" y="${Math.round(startY + i * fontSize * 1.3)}" font-family="Helvetica, Arial, sans-serif" font-size="${fontSize}" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">${escape(line)}</text>`)
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">${stops}</linearGradient></defs>
|
||||
<rect width="${w}" height="${h}" fill="url(#g)"/>
|
||||
<rect x="0" y="0" width="${w}" height="${h}" fill="#000000" fill-opacity="0.22"/>
|
||||
${text}
|
||||
<rect x="${w - Math.round(w / 4.2)}" y="${h - Math.round(h / 16)}" width="${Math.round(w / 4.2)}" height="${Math.round(h / 16)}" fill="#000000" fill-opacity="0.55"/>
|
||||
<text x="${w - Math.round(w / 8.4)}" y="${h - Math.round(h / 32)}" font-family="Helvetica, Arial, sans-serif" font-size="${Math.round(w / 60)}" letter-spacing="2" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">SYNTHETIC COMP</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
// Minimal valid PNG: palette stripes plus a tEXt chunk carrying the marker and
|
||||
// prompt, so a .png/.webp fake stays a decodable image and still contains the
|
||||
// "SYNTHETIC" bytes downstream tools look for.
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c ^= buf[i];
|
||||
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function pngChunk(type, data) {
|
||||
const typeBuf = Buffer.from(type, 'latin1');
|
||||
const body = Buffer.concat([typeBuf, data]);
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body), 0);
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
|
||||
function pngFake(prompt, [w, h]) {
|
||||
const colors = palette(prompt); // [[r,g,b], ...]
|
||||
const bandH = Math.ceil(h / colors.length);
|
||||
// Raw image: each scanline prefixed with a 0 filter byte, RGB pixels.
|
||||
const stride = w * 3;
|
||||
const raw = Buffer.alloc(h * (stride + 1));
|
||||
for (let y = 0; y < h; y++) {
|
||||
const rowStart = y * (stride + 1);
|
||||
raw[rowStart] = 0;
|
||||
const [r, g, b] = colors[Math.min(colors.length - 1, Math.floor(y / bandH))];
|
||||
for (let x = 0; x < w; x++) {
|
||||
const p = rowStart + 1 + x * 3;
|
||||
raw[p] = r;
|
||||
raw[p + 1] = g;
|
||||
raw[p + 2] = b;
|
||||
}
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0);
|
||||
ihdr.writeUInt32BE(h, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // color type: truecolor RGB
|
||||
const idat = zlib.deflateSync(raw, { level: 9 });
|
||||
const textData = Buffer.concat([
|
||||
Buffer.from('Comment', 'latin1'),
|
||||
Buffer.from([0]),
|
||||
Buffer.from(`SYNTHETIC COMP: ${String(prompt).replace(/\s+/g, ' ').trim()}`, 'latin1'),
|
||||
]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
pngChunk('IHDR', ihdr),
|
||||
pngChunk('tEXt', textData),
|
||||
pngChunk('IDAT', idat),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function parseSize(sizeStr) {
|
||||
const m = String(sizeStr).match(/^(\d+)x(\d+)$/);
|
||||
if (!m) return [1536, 1024];
|
||||
return [Number(m[1]), Number(m[2])];
|
||||
}
|
||||
|
||||
if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
|
||||
const fakePromptFile = arg('prompt-file');
|
||||
const fakePrompt = fakePromptFile ? fs.readFileSync(fakePromptFile, 'utf8') : arg('prompt');
|
||||
const fakeOut = arg('out');
|
||||
if (!fakePrompt || !fakeOut) {
|
||||
console.error('generate-image: --prompt (or --prompt-file) and --out are required.');
|
||||
process.exit(1);
|
||||
}
|
||||
const dims = parseSize(arg('size', '1536x1024'));
|
||||
const bytes = fakeOut.endsWith('.svg')
|
||||
? Buffer.from(svgFake(fakePrompt, dims), 'utf8')
|
||||
: pngFake(fakePrompt, dims);
|
||||
fs.writeFileSync(fakeOut, bytes);
|
||||
console.log(`IMAGE: ${fakeOut} (${dims[0]}x${dims[1]}, fake synthetic comp, $0.00, no API call)`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const key = process.env.OPENAI_API_KEY;
|
||||
if (!key) {
|
||||
console.error('generate-image: OPENAI_API_KEY is not set; use the harness-native image tool instead.');
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* touchFile(cache, sessionId, filePath)
|
||||
* suppressionNotice(filePath)
|
||||
* filterFindings(findings, content, ext, config)
|
||||
* ADVISORY_RULES / isAdvisoryFinding(finding)
|
||||
* IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness)
|
||||
* matchConfiguredExtension(filePath, extensions)
|
||||
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
||||
@@ -126,6 +127,26 @@ export const IMMEDIATE_TIER_RULES = new Set([
|
||||
'design-system-font-size',
|
||||
]);
|
||||
|
||||
// ── Advisory rules ────────────────────────────────────────────────────────
|
||||
// Advisory rules are opt-in noise: the CLI reports them in a separate section
|
||||
// and they never count as failures. The design hook skips them entirely by
|
||||
// default — in both the per-edit PostToolUse pass and the Stop deep pass — so
|
||||
// the agent is never nagged about a taste call a human might make on purpose.
|
||||
// A project opts back in with `.impeccable/config.json`:
|
||||
// { "detector": { "advisoryRules": "include" } }
|
||||
// This set is the hook's own copy of the registry's `advisory: true` rules,
|
||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
||||
// self-contained and testable without loading the detector. Keep it in sync
|
||||
// with the registry (cli/engine/registry/antipatterns.mjs).
|
||||
export const ADVISORY_RULES = new Set([
|
||||
'em-dash-overuse',
|
||||
]);
|
||||
|
||||
export function isAdvisoryFinding(finding) {
|
||||
const id = finding && normalizeIgnoreRule(finding.antipattern);
|
||||
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG = Object.freeze({
|
||||
enabled: true,
|
||||
quiet: false,
|
||||
@@ -136,6 +157,9 @@ export const DEFAULT_CONFIG = Object.freeze({
|
||||
ignoreValues: [],
|
||||
extensions: [],
|
||||
perEditRules: 'immediate',
|
||||
// Advisory rules are skipped unless a project sets detector.advisoryRules to
|
||||
// "include". See ADVISORY_RULES above.
|
||||
advisoryRules: 'exclude',
|
||||
// maxFileBytes: not every generated artifact lives under a path we can
|
||||
// recognize. Committed browser bundles and vendored detector copies sit
|
||||
// next to source and run 200KB+, while genuinely authored stylesheets in
|
||||
@@ -293,6 +317,11 @@ function cloneDefaultConfig() {
|
||||
|
||||
function applyDetectorConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
// `detector.advisoryRules: "include"` opts the hook into advisory rules
|
||||
// (em-dash overuse, etc.). Any other value keeps the default "exclude".
|
||||
if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
|
||||
config.advisoryRules = raw.advisoryRules;
|
||||
}
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
@@ -755,8 +784,12 @@ export function filterFindings(findings, _content, _ext, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config.ignoreValues || []);
|
||||
// Advisory rules are skipped by default so the hook never nags about them;
|
||||
// a project opts in with detector.advisoryRules: "include".
|
||||
const includeAdvisory = (config?.advisoryRules || DEFAULT_CONFIG.advisoryRules) === 'include';
|
||||
return findings.filter((f) => {
|
||||
if (!f || typeof f !== 'object') return false;
|
||||
if (!includeAdvisory && isAdvisoryFinding(f)) return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(f, ignoreValues)) return false;
|
||||
return true;
|
||||
|
||||
@@ -43,7 +43,7 @@ function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
@@ -71,6 +71,11 @@ function cloneRawDetectionConfig() {
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
// Advisory rules are opt-in for the design hook; the CLI carries the setting
|
||||
// so config round-trips (e.g. `impeccable hooks ignore-value`) preserve it.
|
||||
if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
|
||||
config.advisoryRules = raw.advisoryRules;
|
||||
}
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
@@ -151,6 +156,9 @@ function normalizeDetectionConfigForWrite(config) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.advisoryRules === 'include' || config?.advisoryRules === 'exclude') {
|
||||
out.advisoryRules = config.advisoryRules;
|
||||
}
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
* }, ...
|
||||
* ],
|
||||
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
|
||||
* "canon": true, // adds the quiet "Play it straight" standing exit
|
||||
* // (returns {"optionId":"canon"}); direction rounds only
|
||||
* "steer": true // adds a free-text steer field returned with any answer
|
||||
* }
|
||||
*
|
||||
@@ -93,6 +95,24 @@ if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) {
|
||||
}
|
||||
}
|
||||
|
||||
// Both answer channels (blocking stdout and --wait collection) print through
|
||||
// this: the ANSWER line, then a directive to open the chosen card's imagery
|
||||
// when it has any. The card viewing happens at the moment of choice, in the
|
||||
// working turn, because a build that never reopens the chosen world's board
|
||||
// and hero calibrates on nothing.
|
||||
function printAnswer(raw) {
|
||||
console.log(`ANSWER: ${raw}`);
|
||||
try {
|
||||
const a = JSON.parse(raw);
|
||||
if (a.hero || a.board) {
|
||||
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code (download local copies first when your harness only reads files); they set the craft bar the build must reach.");
|
||||
}
|
||||
if (a.optionId === 'canon') {
|
||||
console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.');
|
||||
}
|
||||
} catch { /* raw answer */ }
|
||||
}
|
||||
|
||||
const payloadPath = arg('payload');
|
||||
const timeoutSec = Number(arg('timeout', '900'));
|
||||
const portArg = Number(arg('port', '0'));
|
||||
@@ -109,9 +129,10 @@ if (hasFlag('schema')) {
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', body: 'Fused alternate.', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
],
|
||||
reroll: true,
|
||||
canon: true,
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" is reserved. hero/board accept URLs or local paths.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board accept URLs or local paths. canon adds a quiet standing "Play it straight" action for direction decisions: the user\'s explicit door to the category standard. Include it only for visual-direction rounds; never present canon as your own recommendation.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -144,7 +165,7 @@ if (hasFlag('wait')) {
|
||||
}
|
||||
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
|
||||
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
|
||||
console.log(`ANSWER: ${collected}`);
|
||||
printAnswer(collected);
|
||||
// A re-roll keeps the table open: the server stays alive awaiting --update,
|
||||
// so only the answer file is consumed. Terminal choices clean up fully.
|
||||
let isRerollAnswer = false;
|
||||
@@ -242,12 +263,12 @@ function page() {
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${option.heroSrc || option.boardSrc ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : ''}
|
||||
<div class="media">
|
||||
${option.heroSrc ? `<img src="${esc(option.heroSrc)}" alt="">` : '<div class="hero-blank"></div>'}
|
||||
<div class="chips">${option.heroSrc ? expandChip : ''}${option.boardSrc ? flipChip('Board') : ''}</div>
|
||||
</div>
|
||||
${option.heroSrc || option.boardSrc ? `<div class="media">
|
||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||
<div class="chips">${expandChip}${option.boardSrc && option.heroSrc ? flipChip('Board') : ''}</div>
|
||||
</div>` : ''}
|
||||
<div class="body">
|
||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||
<h2>${esc(option.label)}</h2>
|
||||
@@ -255,7 +276,7 @@ function page() {
|
||||
<button class="choose" data-id="${esc(option.id)}">Build this</button>
|
||||
</div>
|
||||
</div>
|
||||
${option.boardSrc ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
${option.boardSrc && option.heroSrc ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
<div class="media back-media">
|
||||
<img src="${esc(option.boardSrc)}" alt="">
|
||||
<div class="chips">${expandChip}${flipChip('Hero')}</div>
|
||||
@@ -328,6 +349,10 @@ function page() {
|
||||
.card:hover .face.lead { border-color: var(--ks-kinpaku); }
|
||||
@media (prefers-reduced-motion: reduce) { .card-inner { transition: none; } }
|
||||
.kicker { position: absolute; z-index: 2; top: 12px; left: 12px; padding: 4px 10px; background: var(--ks-kinpaku); color: var(--ks-dark-ink); font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .24em; text-transform: uppercase; border-radius: 4px; }
|
||||
/* Text-only card: a grounded direction with no rendered card drops the media
|
||||
region entirely instead of reserving a blank 16:9 void. */
|
||||
.face.text-only .kicker { position: static; align-self: flex-start; margin: 14px 0 0 14px; }
|
||||
.face.text-only .body { padding-top: 12px; }
|
||||
.media { position: relative; width: 100%; aspect-ratio: 16/9; flex: none; }
|
||||
.media img { width: 100%; height: 100%; object-fit: cover; display: block; background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
|
||||
.face.back { background: var(--ks-lacquer-deep); }
|
||||
@@ -350,6 +375,11 @@ function page() {
|
||||
#reroll { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
#reroll svg { width: 15px; height: 15px; }
|
||||
/* The quiet exit: always available, never argued with, visually subordinate
|
||||
to the dealt cards and the re-roll so it reads as the user's own door,
|
||||
not a recommendation. */
|
||||
#canon { align-self: center; padding: 0 4px; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: inherit; opacity: .45; background: transparent; border: none; border-bottom: 1px dotted currentColor; cursor: pointer; transition: opacity .2s ease; }
|
||||
#canon:hover { opacity: .85; }
|
||||
.card.skeleton .media { background: var(--ks-graphite); }
|
||||
.shimmer { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
|
||||
.card.skeleton .line { height: 11px; border-radius: 4px; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
|
||||
@@ -384,6 +414,7 @@ function page() {
|
||||
<footer>
|
||||
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
|
||||
${payload.reroll ? '<button id="reroll"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg><span>Re-roll</span></button>' : ''}
|
||||
${payload.canon ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
|
||||
</footer>
|
||||
<script>
|
||||
const steer = () => document.getElementById('steer')?.value || '';
|
||||
@@ -454,6 +485,7 @@ function page() {
|
||||
const closeLightbox = () => { lightbox.classList.remove('open'); setTimeout(() => { lightbox.hidden = true; }, 250); };
|
||||
lightbox.addEventListener('click', closeLightbox);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
||||
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
|
||||
document.getElementById('reroll')?.addEventListener('click', async () => {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
|
||||
const grid = document.querySelector('.grid');
|
||||
@@ -518,7 +550,11 @@ const server = http.createServer((req, res) => {
|
||||
if (imageMatch) {
|
||||
const abs = localImages[Number(imageMatch[1])];
|
||||
if (!abs) { res.writeHead(404); res.end(); return; }
|
||||
const type = abs.endsWith('.webp') ? 'image/webp' : abs.endsWith('.png') ? 'image/png' : 'image/jpeg';
|
||||
const type = abs.endsWith('.webp') ? 'image/webp'
|
||||
: abs.endsWith('.png') ? 'image/png'
|
||||
: abs.endsWith('.svg') ? 'image/svg+xml'
|
||||
: abs.endsWith('.gif') ? 'image/gif'
|
||||
: 'image/jpeg';
|
||||
res.writeHead(200, { 'content-type': type });
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
return;
|
||||
@@ -531,13 +567,18 @@ const server = http.createServer((req, res) => {
|
||||
res.end('{"ok":true}');
|
||||
let parsed = {};
|
||||
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
|
||||
const answer = JSON.stringify({ optionId: parsed.optionId ?? null, steer: parsed.steer ?? '' });
|
||||
const chosen = options.find((o) => o.id === parsed.optionId);
|
||||
const answer = JSON.stringify({
|
||||
optionId: parsed.optionId ?? null,
|
||||
steer: parsed.steer ?? '',
|
||||
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||
});
|
||||
const isReroll = parsed.optionId === 'reroll';
|
||||
if (detachedKey) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
|
||||
} else {
|
||||
console.log(`ANSWER: ${answer}`);
|
||||
printAnswer(answer);
|
||||
}
|
||||
// A re-roll in detached mode keeps the table open: the client shows a
|
||||
// loading hand and reloads when --update delivers the next round.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{
|
||||
"name": "impeccable",
|
||||
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
||||
"version": "4.0.0",
|
||||
"version": "4.0.1",
|
||||
"author": {
|
||||
"name": "Paul Bakaus",
|
||||
"email": "paul@paulbakaus.com"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "impeccable",
|
||||
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
||||
"version": "4.0.0",
|
||||
"version": "4.0.1",
|
||||
"author": {
|
||||
"name": "Paul Bakaus",
|
||||
"email": "paul@paulbakaus.com"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: impeccable-asset-producer
|
||||
description: Produces clean reusable raster assets from approved Impeccable mock references without redesigning the direction.
|
||||
tools: Read, Write, Edit, Bash, Glob, Grep
|
||||
model: inherit
|
||||
effort: medium
|
||||
maxTurns: 12
|
||||
---
|
||||
# Impeccable Asset Producer
|
||||
|
||||
You are the asset production agent for Impeccable craft.
|
||||
|
||||
Your job is production cleanup, not new art direction. Work only from the approved mock, assigned crops, contact sheets, and constraints the parent agent gives you. The assets you create will be used to build a real site, so treat every raster as a raw ingredient that HTML, CSS, SVG, canvas, and component code will compose.
|
||||
|
||||
## Core Rule
|
||||
|
||||
Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster.
|
||||
|
||||
## Input Contract
|
||||
|
||||
Expect:
|
||||
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
Use defaults unless contradicted:
|
||||
|
||||
- `.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. Do not use small full-page mock crop size as the default shipping size.
|
||||
- Remove UI text, navigation, buttons, labels, and body copy by default.
|
||||
- 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 to the asset.
|
||||
- Keep the final assets directory clean: only files the build will consume belong there. Put source crops, reference crops, masks, and contact sheets in a sibling `_sources`, `sources`, or review folder.
|
||||
|
||||
Ask blockers once, globally. Missing source path/crops or output directory blocks production. Exact dimensions, compression targets, retina variants, and format preferences do not block; choose defaults and report them.
|
||||
|
||||
## 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`: can ship as a crop, format conversion, compression pass, or sourced replacement with no generative cleanup.
|
||||
- `semantic`: build in HTML/CSS/SVG/canvas, no raster output.
|
||||
3. Treat full-page mock crops as references, not production-resolution source assets. Put a role in `direct` only when the provided source is already a clean, sufficiently large source asset with no semantic text or presentation chrome.
|
||||
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 semantic HTML/CSS/SVG recommendation if raster is wrong.
|
||||
6. Treat every crop as binding reference. Use the harness's native image tool by default when generation or editing is needed<codex> (the imagegen skill's built-in `image_gen` path)</codex>; 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.
|
||||
10. Compare each output against its source crop. If a review/QA tool is available, run it before the final manifest, then retry each major/fatal finding once before finalizing.
|
||||
|
||||
Use `direct` only for provided source assets that can already ship after crop tightening, conversion, compression, or naming. Do not ship a small crop from the full-page mock as `direct` just because it looks close.
|
||||
|
||||
Use `texture/pattern extraction` only when the source region is already clean enough to sample as texture. If UI, cards, labels, headings, body copy, or footer chrome must be removed to make a reusable texture or background, classify it as crop-derived cleanup or clean-plate work.
|
||||
|
||||
Use `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. Only ship a screenshot raster 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: name the DOM/component layers, CSS-owned visual treatment, SVG/canvas/icon-library pieces, responsive behavior, and which nearby produced raster assets it should compose with. For logos and icons, prefer inline SVG/vector or icon-library implementation unless the parent provides a production logo raster.
|
||||
|
||||
For transparency, prefer true alpha output when the tool supports it. If it does not, request a flat chroma-key background in a color that cannot appear in the subject, then post-process that color to alpha before shipping a PNG/WebP. Do not ship the keyed background as the final asset.
|
||||
|
||||
## 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 a chroma-key workflow by default<codex> (the imagegen skill's built-in-first path)</codex>: generate on a flat color that cannot appear in the subject, then post-process to alpha; use true native transparency only when the tool supports it or the parent authorizes it.
|
||||
|
||||
## Output Contract
|
||||
|
||||
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` must be a concrete build handoff, not a short explanation that no asset was produced. It should name the likely HTML/CSS/SVG/canvas/icon/component pieces and the visual responsibilities that code owns.
|
||||
|
||||
`qa_status` must be `accepted`, `needs_parent_review`, or `blocked`. Use `accepted` only after visual comparison passes. Use `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. Use `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. Do not repeat missing inputs in every row; per-asset rows should 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.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: impeccable-finish-reviewer
|
||||
description: Reviews a finished Impeccable build against its direction contract, persistence requirements, and the chosen world's quality bar, returning an ordered list of material fixes.
|
||||
tools: Read, Bash, Glob, Grep
|
||||
model: inherit
|
||||
effort: high
|
||||
maxTurns: 12
|
||||
---
|
||||
# Impeccable Finish Reviewer
|
||||
|
||||
You are the finishing reviewer for an Impeccable build: fresh eyes on a done artifact, outside the build thread's attention gravity. You do not edit anything; the parent agent applies your fixes.
|
||||
|
||||
## Input Contract
|
||||
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md and DESIGN.md paths; existing hook or detector findings; the chosen world's QUALITY BAR card paths and approved comp paths when they exist; screenshot path(s) when available. When the harness can view images, open the card, the comp, and the screenshot before judging.
|
||||
|
||||
## Checks, in order
|
||||
|
||||
1. **Persistence.** On a new or replacement world: PRODUCT.md and DESIGN.md exist, and DESIGN.md matches the built world. A missing or mismatched file is the first material fix, ahead of any craft point.
|
||||
2. **Ceiling.** Against the QUALITY BAR card and the approved comp, name the world's native devices the build left unused: frame, depth, lettering treatment, ornament density, motion. Compare commitment and finish, never composition; the card is a bar, not a layout.
|
||||
3. **Contract, promise by promise.** For each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport: what would a visitor describe an hour later, and is it the thesis or a mood?
|
||||
4. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions.
|
||||
|
||||
Do not run a second detector pass; mechanical findings belong to the parent's hooks.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Return exactly four sections: `persistence` (pass/fail with specifics), `ceiling` (the unused native devices, or "reached"), `material_fixes` (ordered, most material first, each one line tied to a check or contract promise, at most eight), and `keep` (one line naming what must not be diluted while fixing). No praise, no summary prose.
|
||||
@@ -7,7 +7,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
|
||||
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 5,
|
||||
"statusMessage": "Checking UI changes"
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
|
||||
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Design deep pass"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 4.0.0
|
||||
version: 4.0.1
|
||||
user-invocable: true
|
||||
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
|
||||
license: Apache 2.0
|
||||
|
||||
@@ -44,9 +44,11 @@ The script assigns which structure gets built: your top-ranked structure is what
|
||||
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, the notation, publications, identity programs, data graphics, and interfaces it reads daily, not only its physical objects; a nameable abstract system (a school of poster, a documentation standard, a data-graphic tradition) 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; the audience's world is larger than that, so dig until the list spans at least three families.
|
||||
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
|
||||
4. Run `node .claude/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Offer re-roll with an optional one-line steer instead of a ranked menu; a lineup invites the safest card. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Pick the channel by capability, not by habit: can you put a page in front of the user, through an in-app browser or by opening a browser window on their machine outside your harness? If yes, present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll plus steer enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll, then run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, preferring the in-app browser when the harness has one, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. If no, because the session is headless, CI, an eval worker, or a remote shell with no display, skip the page and put the same decision through the structured question tool; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the one or two fused challengers that survived the weighing as named alternates with a one-line case each, plus re-roll with an optional one-line steer. What you never present is a ranked menu of your own grounded candidates; a lineup of those invites the safest card, while dealt challengers carry no such rut. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list also carries the standing exit as its last option.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. When the harness can view images, open the QUALITY BAR board and hero the seed prints for the world you build (when it only reads local images, download the card to a temp file first and view that): they set the craft level the build must reach, the finish, commitment, and art direction of a rendered reference, and never dictate the composition; your surface serves this product.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images for exactly this reason (download to a temp file when the harness only reads local images). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
|
||||
|
||||
Every direction the roll can land on must already be viable: every relationship and claim it visualizes true, a real palette and component family, a distinctive composition with one product-specific experience, workable at full-surface scale within the available assets, tools, and performance budget. A candidate that fails on truth is replaced before the roll, never rescued by it. Truth binds claims, not demonstrations: in greenfield work, author whatever illustrative material the concept needs at full fidelity, label it synthetic wherever a visitor could mistake it for the real thing, and hand the user the list of what to replace with real material. What stays uninventable are commercial and factual claims: prices, customers, benchmarks, endpoints, capabilities the product does not have. Refusing a bold direction because its demonstration data does not exist yet is the timidity reflex wearing honesty's clothes.
|
||||
|
||||
@@ -64,10 +66,12 @@ Your measured rendition prior: warm, bookish, family, and child-facing subjects
|
||||
|
||||
## 5. Record the decision
|
||||
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. 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. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, the staging chosen for it, and the seed key the script printed; when the seed dealt stagings, choosing the world does not silently discharge the staging decision, so name the one you commit to. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
|
||||
When a new or replacement world is chosen, DESIGN.md is part of recording the decision, not an aftercare step: write it at the appropriate project or app boundary using [document.md](document.md) before the first build edit lands, in the same working stretch as the direction contract. Record only durable system rules; exact tokens may remain provisional until the first build establishes them, and you update the file when the build settles them. A new world shipped with no DESIGN.md is an incomplete run, exactly as a missing PRODUCT.md is; the finishing review checks the file exists and matches the built world. An ordinary extension does not rewrite DESIGN.md.
|
||||
|
||||
Two observed ways a DESIGN.md rule goes wrong: a prohibition that bans a device the world itself uses natively (arcade lettering lives on its offset shadow), and a token added so a hook finding stops firing. Check every prohibition against the world's own materials; a value earns its place by the world and by legibility, never by silencing a check.
|
||||
|
||||
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
|
||||
|
||||
`node .claude/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
|
||||
@@ -76,7 +80,7 @@ If the work establishes durable strategy for a route or artifact, read its exist
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
Visualize before you build whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports: render the chosen direction as a design-system board and a first-surface mock, correct material drift between mock and intent, then build. Seeing the direction first measurably strengthens the result. [codex.md](codex.md) carries the deep native-generation flow; the mock is a selection aid, not authority.
|
||||
Whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options rendered and put before the user for approval. This step is proven to produce the most compositional and ambitious work.
|
||||
|
||||
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
|
||||
|
||||
@@ -86,8 +90,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie
|
||||
|
||||
- **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. 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; never invent prices, customers, benchmarks, or capabilities.
|
||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, titles, 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, and no section is omittable. When a commercial claim stayed unanswered, ship a clearly marked placeholder value and hand the user the replacement list; a section thinned or dropped for missing truth is the asset gap wearing honesty's clothes. Decoration compensating for missing content, gradients, glass, borders, icon tiles where an authored asset belongs, is the same gap wearing chrome's.
|
||||
- **Generate the imagery the build needs.** When any image generation is available, a harness-native tool or the API fallback context.mjs reports, generating the design's imagery is part of building: heroes, covers, demonstration thumbnails, textures, marks. Gray boxes and stock-styled placeholders are not a fallback while generation is one command away; state the cost once before the first render and batch what the surface needs.
|
||||
- **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 that before a line of code exists. 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.
|
||||
@@ -99,4 +102,4 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
||||
|
||||
Inspect desktop and mobile, critique the render against the user's request, the direction contract, and DESIGN.md, fix material gaps, and re-inspect. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary. After a first implementation of a new world, update DESIGN.md with the exact tokens and behaviors that survived the build.
|
||||
|
||||
When the harness can run a separate agent, this review belongs there, not in the build thread: give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, and existing hook findings. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector.
|
||||
Spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex), and give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, existing hook findings, and the QUALITY BAR card and approved comp paths. This review never runs inside the build thread; a harness with no subagents runs it as its own fresh pass, after stepping fully out of the build context. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Its second check is the ceiling: against the chosen world's QUALITY BAR card and the approved comp, name the native devices the build left unused, frame, depth, lettering, ornament density; floor rigor is very good at disguising an unreached card. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector.
|
||||
|
||||
+8
-8
@@ -1,15 +1,15 @@
|
||||
# Codex: Surface Probes & Asset Production
|
||||
# Visualize: Direction Comps & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) only when the harness has native image generation and a substantial, high-fidelity surface would benefit from seeing the shortlisted concept before code. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
Load this from [new-work.md](new-work.md) whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate the smallest useful probe set
|
||||
## Generate three compositional options
|
||||
|
||||
Generate one to three high-fidelity north-star comps using the native image-generation capability. Base them on the real content and the surface concepts already developed with the user.
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building.
|
||||
|
||||
- When the user shortlisted multiple concepts, show one clear expression of each.
|
||||
- When one concept is already selected, vary only the structural uncertainty that the image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||
- When the user shortlisted multiple concepts, spread the three across them.
|
||||
- When one direction is committed, vary the structural uncertainty an image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||
- Show enough beyond the opening moment to prove the concept can govern the whole requested surface.
|
||||
- Do not generate a palette artifact, ask new atmosphere questions, introduce a different type voice, or invent a new motif. If the committed world cannot support the concept, return to the concept shortlist rather than changing the world.
|
||||
|
||||
@@ -17,7 +17,7 @@ Treat each comp as a direction test, not a screenshot specification. Core UI tex
|
||||
|
||||
## One approval point
|
||||
|
||||
Show the probes together and ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
Show the three together: in the harness when it can display images, otherwise on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero). Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
@@ -33,6 +33,6 @@ Treat the comp as a north star, not something to trace. Do not rasterize core UI
|
||||
|
||||
## Produce only the assets the build needs
|
||||
|
||||
When clean raster ingredients are required and a scoped subagent is available and authorized, use `impeccable_asset_producer`. Give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets with the native image-generation capability in the current thread.
|
||||
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread with whatever generation exists, the native tool or generate-image.mjs.
|
||||
|
||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||
@@ -79,6 +79,13 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_DIR = process.env.IMPECCABLE_CATALOG_DIR || here;
|
||||
const API_BASE = (process.env.IMPECCABLE_API_URL || 'https://impeccable.style/api').replace(/\/$/, '');
|
||||
const API_TIMEOUT_MS = Number(process.env.IMPECCABLE_API_TIMEOUT || 4000);
|
||||
// All API calls in one seed run share a single deadline so an unreachable
|
||||
// network degrades after one timeout total, never one timeout per call.
|
||||
let apiDeadline = null;
|
||||
function apiBudgetMs() {
|
||||
if (apiDeadline === null) apiDeadline = Date.now() + API_TIMEOUT_MS;
|
||||
return Math.max(0, apiDeadline - Date.now());
|
||||
}
|
||||
|
||||
const localStates = new Map();
|
||||
function loadLocal(catalogDir = CATALOG_DIR) {
|
||||
@@ -120,9 +127,15 @@ async function fetchRoll({ scope, key, mode, reroll }) {
|
||||
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
|
||||
if (mode) params.set('mode', mode);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal });
|
||||
// Race the budget explicitly: abort signals do not reliably cancel the
|
||||
// TCP connect phase, so a blackholed route would otherwise stall ~10s.
|
||||
const response = await Promise.race([
|
||||
fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal }),
|
||||
new Promise(resolveTimeout => setTimeout(() => resolveTimeout(null), apiBudgetMs())),
|
||||
]);
|
||||
if (!response) return null;
|
||||
if (!response.ok) return null;
|
||||
const roll = await response.json();
|
||||
if (!Array.isArray(roll.challengers) || roll.challengers.length === 0) return null;
|
||||
@@ -143,7 +156,7 @@ function telemetryDisabled() {
|
||||
export async function pingChosen({ chosenId, key, scope, mode }) {
|
||||
if (telemetryDisabled() || !chosenId) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
await fetch(`${API_BASE}/chosen`, {
|
||||
method: 'POST',
|
||||
@@ -445,7 +458,10 @@ ASSIGNED INDEX: ${buildIndex}
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
No challengers this run: the roll service was unreachable and no local
|
||||
catalog exists. Proceed with the grounded candidates alone; the assignment
|
||||
catalog exists. A sandboxed shell without network egress is the most common
|
||||
cause: if this harness can rerun the command with network access granted,
|
||||
do that once before proceeding. Otherwise proceed with the grounded
|
||||
candidates alone; the assignment
|
||||
above still applies at full strength. Tell the user plainly that this roll
|
||||
ran degraded, with no challengers and no quality-bar boards; do not present
|
||||
the outcome as a full roll. A degraded roll changes the cards, not the
|
||||
@@ -543,4 +559,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
// A raced-out fetch may still hold a socket; exit explicitly so the CLI
|
||||
// never lingers on a dead network path after output is written.
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
|
||||
@@ -1223,6 +1223,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -1541,6 +1545,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadDesignSystemForCwd } from '../design-system.mjs';
|
||||
import { loadDesignSystemForTarget } from '../design-system.mjs';
|
||||
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs';
|
||||
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
|
||||
import { detectHtml } from '../engines/static-html/detect-html.mjs';
|
||||
@@ -27,9 +28,37 @@ function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
// Local filesystem path behind a file:// URL, or null when it can't be mapped.
|
||||
function fileUrlToLocalPath(url) {
|
||||
try {
|
||||
return fileURLToPath(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Advisory findings are detected but never treated as failures: they list in a
|
||||
// separate, visually dimmed section, are excluded from the failure count that
|
||||
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
|
||||
// filter. Every advisory finding carries the flag (stamped by the registry via
|
||||
// findings.mjs).
|
||||
function isAdvisory(finding) {
|
||||
return finding && finding.advisory === true;
|
||||
}
|
||||
|
||||
function partitionAdvisory(findings) {
|
||||
const primary = [];
|
||||
const advisory = [];
|
||||
for (const f of findings) (isAdvisory(f) ? advisory : primary).push(f);
|
||||
return { primary, advisory };
|
||||
}
|
||||
|
||||
// ANSI dim, when stderr is a TTY. Advisory output is chrome, so keep it quiet.
|
||||
function dim(text) {
|
||||
return process.stderr.isTTY ? `\x1b[2m${text}\x1b[0m` : text;
|
||||
}
|
||||
|
||||
function formatFindingsBody(findings) {
|
||||
const grouped = {};
|
||||
for (const f of findings) {
|
||||
if (!grouped[f.file]) grouped[f.file] = [];
|
||||
@@ -44,7 +73,28 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatAdvisorySection(advisory) {
|
||||
if (!advisory || advisory.length === 0) return '';
|
||||
const lines = [`\n${dim('── Advisory (not counted as failures) ──')}`];
|
||||
for (const line of formatFindingsBody(advisory)) lines.push(dim(line));
|
||||
lines.push(dim(`\n${advisory.length} advisory note${advisory.length === 1 ? '' : 's'}. Suppress with --no-advisory.`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Text/JSON formatter. `findings` is the full set; advisory items are separated
|
||||
// out into their own section and excluded from the failure summary count. JSON
|
||||
// output keeps every finding (each advisory one flagged) in a single array.
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
const { primary, advisory } = partitionAdvisory(findings);
|
||||
const out = [...formatFindingsBody(primary)];
|
||||
out.push(`\n${formatFindingSummary(primary.length)}`);
|
||||
const advisorySection = formatAdvisorySection(advisory);
|
||||
if (advisorySection) out.push(advisorySection);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -52,7 +102,11 @@ function formatFindings(findings, jsonMode) {
|
||||
// Stdin handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleStdin(options = {}) {
|
||||
// `optionsFor` maps a local path to scan options carrying that path's own
|
||||
// project design system (or base options when null). Falls back to a plain
|
||||
// object so direct/legacy callers still work.
|
||||
async function handleStdin(optionsFor = () => ({})) {
|
||||
const resolve = typeof optionsFor === 'function' ? optionsFor : () => optionsFor;
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const input = Buffer.concat(chunks).toString('utf-8');
|
||||
@@ -60,11 +114,12 @@ async function handleStdin(options = {}) {
|
||||
const parsed = JSON.parse(input);
|
||||
const fp = parsed?.tool_input?.file_path;
|
||||
if (fp && fs.existsSync(fp)) {
|
||||
const options = resolve(fp);
|
||||
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
|
||||
? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options);
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return detectText(input, '<stdin>', options);
|
||||
return detectText(input, '<stdin>', resolve(null));
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +155,14 @@ Options:
|
||||
ignore comments, or DESIGN.md
|
||||
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
|
||||
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
|
||||
--no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)
|
||||
--help Show this help message
|
||||
|
||||
Advisory findings:
|
||||
Some rules are advisory: detected and listed in a separate section, but never
|
||||
counted as failures and never changing the exit code. They stay out of the
|
||||
failure count so they never block automation. --no-advisory hides them.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -139,6 +200,7 @@ async function detectCli() {
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
const noAdvisory = args.includes('--no-advisory');
|
||||
// --fast (regex-only) is deprecated: since the jsdom removal, the static
|
||||
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
|
||||
// only loses coverage for no real speed win. Accept the flag for back-compat
|
||||
@@ -199,14 +261,23 @@ async function detectCli() {
|
||||
process.exit(1);
|
||||
}
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
|
||||
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
|
||||
// apply by default. `--no-config` (raw scan) and the dedicated
|
||||
// `--no-inline-ignores` both turn them off.
|
||||
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
|
||||
const scanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
if (viewport) scanOptions.viewport = viewport;
|
||||
const baseScanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (viewport) baseScanOptions.viewport = viewport;
|
||||
// DESIGN.md must resolve from EACH scan target's own project root, not from
|
||||
// process.cwd(): scanning project B's files from inside project A applied A's
|
||||
// design rules (cross-project contamination). Resolve per target, memoized by
|
||||
// resolved project root so a multi-file scan pays the read once per project.
|
||||
// A target with no project marker above it gets no design system (never cwd's).
|
||||
const designSystemCache = new Map();
|
||||
const scanOptionsFor = (localPath) => {
|
||||
if (!designSystemEnabled || !localPath) return baseScanOptions;
|
||||
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
||||
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
|
||||
};
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -214,7 +285,7 @@ async function detectCli() {
|
||||
let allFindings = [];
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptions);
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
} else {
|
||||
const paths = targets.length > 0 ? targets : [process.cwd()];
|
||||
// file:// URLs get the same Puppeteer-rendered pass as http(s) — the
|
||||
@@ -228,10 +299,17 @@ async function detectCli() {
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (urlRe.test(target)) {
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
// process.cwd()'s.
|
||||
const urlOptions = /^file:/i.test(target)
|
||||
? scanOptionsFor(fileUrlToLocalPath(target))
|
||||
: baseScanOptions;
|
||||
try {
|
||||
const scanner = browserDetector
|
||||
? (url) => browserDetector.detectUrl(url, scanOptions)
|
||||
: (url) => detectUrl(url, scanOptions);
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
@@ -297,11 +375,14 @@ async function detectCli() {
|
||||
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
let fileFindings;
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
fileFindings = await detectHtml(file, scanOptions);
|
||||
fileFindings = await detectHtml(file, fileOptions);
|
||||
} else {
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions);
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, fileOptions);
|
||||
}
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
@@ -316,10 +397,11 @@ async function detectCli() {
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
allFindings.push(...await detectHtml(resolved, scanOptions));
|
||||
allFindings.push(...await detectHtml(resolved, fileOptions));
|
||||
} else {
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions));
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,12 +412,24 @@ async function detectCli() {
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
// --no-advisory drops advisory findings before any output or exit-code math.
|
||||
if (noAdvisory) allFindings = allFindings.filter((f) => !isAdvisory(f));
|
||||
|
||||
// The exit code and failure count reflect non-advisory findings only. An
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else if (quietMode) {
|
||||
process.stderr.write(formatFindingSummary(primary.length) + '\n');
|
||||
if (advisory.length > 0) {
|
||||
process.stderr.write(dim(`${advisory.length} advisory note${advisory.length === 1 ? '' : 's'} (not counted).`) + '\n');
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { finding } from './findings.mjs';
|
||||
@@ -7,6 +8,11 @@ import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs';
|
||||
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// Files/dirs whose presence marks a directory as a project root. Mirrors the
|
||||
// walk-up semantics of skill/scripts/context.mjs (`resolveProject`), which the
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
@@ -469,6 +475,62 @@ function loadDesignSystemForCwd(cwd = process.cwd()) {
|
||||
});
|
||||
}
|
||||
|
||||
// Directory to begin the project-root walk from, given a scan target that may
|
||||
// be a file or a directory (and may not exist yet).
|
||||
function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
return fs.statSync(abs).isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
// Nonexistent path: treat an extension-bearing leaf as a file.
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
// Returns { dir, hasDesign } for the stopping directory, or null when the walk
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the design system that governs a specific scan target, by walking up
|
||||
// from the target's own location — never process.cwd(). Scanning project B's
|
||||
// files from inside project A applies B's DESIGN.md (or none), not A's.
|
||||
//
|
||||
// Pass a `cache` Map to memoize by resolved design root across a multi-file
|
||||
// scan; a target with no design root above it resolves to null.
|
||||
export function loadDesignSystemForTarget(targetPath, { cache, cwd = process.cwd() } = {}) {
|
||||
const startDir = designSystemStartDir(targetPath, cwd);
|
||||
const found = findDesignRoot(startDir);
|
||||
const key = found ? `root:${found.dir}` : '\0none';
|
||||
if (cache && cache.has(key)) return cache.get(key);
|
||||
const loaded = found?.hasDesign ? loadDesignSystemForCwd(found.dir) : null;
|
||||
if (cache) cache.set(key, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function isAllowedFont(font, designSystem) {
|
||||
if (!font || GENERIC_FONTS.has(font)) return true;
|
||||
if (!designSystem?.hasFonts) return true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS, EM_DASH_FLOOR, EM_DASH_CHARS_PER_DASH } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
@@ -16,6 +16,7 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
|
||||
const hasBorderRadius = (line) => /border-radius/i.test(line);
|
||||
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
|
||||
|
||||
|
||||
/** Strip HTML to plain text — drops script/style/comments/tags so
|
||||
* content-text analyzers don't false-positive on code or CSS. */
|
||||
function stripHtmlToText(html) {
|
||||
@@ -306,15 +307,34 @@ const REGEX_ANALYZERS = [
|
||||
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
||||
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
|
||||
},
|
||||
// Em-dash overuse: 5+ em-dashes or "--" in body text content
|
||||
// (occasional em-dash use in prose is fine; the pattern fires only
|
||||
// when count crosses into AI-cadence territory).
|
||||
// Em-dash overuse (ADVISORY): the AI cadence tell is em-dash *saturation*,
|
||||
// not the occasional dash. Humans use em-dashes legitimately, so this rule is
|
||||
// advisory (surfaced separately, never a failure, hook-skipped by default) and
|
||||
// its threshold is deliberately conservative. Two gates must both hold:
|
||||
// 1. Absolute floor of EM_DASH_FLOOR (8) dashes — a page with a handful
|
||||
// never fires, no matter how short.
|
||||
// 2. Density: at least one dash per EM_DASH_CHARS_PER_DASH (500) characters
|
||||
// of body text, so a long article that uses eight across several thousand
|
||||
// words is left alone while a short, dash-per-clause landing page is not.
|
||||
// Raised from the old flat 5-dash floor, which fired on ordinary long prose.
|
||||
//
|
||||
// stripHtmlToText drops tags but leaves character-entity escapes intact, so
|
||||
// a model that writes `—`, `—`, or `—` renders an em-dash
|
||||
// the counter never saw. Decode the em-dash entities (named, zero-padded
|
||||
// decimal, upper/lower hex) to the literal glyph first. En-dash entities are
|
||||
// deliberately left alone: the rule counts em-dashes, and the literal `–`
|
||||
// was never counted either.
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
const text = stripHtmlToText(content)
|
||||
.replace(/—|�*8212;|�*2014;/gi, '—');
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(text) !== null) count++;
|
||||
if (count < 5) return [];
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
// Saturation gate: dashes must be dense in the prose, not sprinkled through
|
||||
// a long document. textLength <= count * chars-per-dash means the density is
|
||||
// at or above the threshold.
|
||||
if (text.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
|
||||
},
|
||||
// Marketing buzzwords: SaaS phrase list
|
||||
|
||||
@@ -6,7 +6,13 @@ function getAP(id) {
|
||||
|
||||
function finding(id, filePath, snippet, line = 0) {
|
||||
const ap = getAP(id);
|
||||
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
// Advisory findings are detected but reported separately and never counted as
|
||||
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
|
||||
// can partition without a registry lookup. Only stamped when true to keep the
|
||||
// finding shape stable for the vast majority of rules.
|
||||
if (ap.advisory === true) base.advisory = true;
|
||||
return base;
|
||||
}
|
||||
|
||||
export { getAP, finding };
|
||||
|
||||
@@ -213,9 +213,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -405,6 +410,14 @@ const ANTIPATTERNS = [
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
},
|
||||
{
|
||||
id: 'undersized-ui-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Undersized functional text',
|
||||
description:
|
||||
'Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.',
|
||||
},
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
@@ -556,6 +569,18 @@ function getAntipattern(id) {
|
||||
return ANTIPATTERNS.find(rule => rule.id === id);
|
||||
}
|
||||
|
||||
// Advisory rules are detected and reported, but never treated as failures:
|
||||
// the CLI lists them under a separate "Advisory" section, they do not affect
|
||||
// exit codes or the failure count, and the design hook skips them by default.
|
||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
||||
const ADVISORY_RULE_IDS = new Set(
|
||||
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
|
||||
);
|
||||
|
||||
function isAdvisoryRule(id) {
|
||||
return ADVISORY_RULE_IDS.has(id);
|
||||
}
|
||||
|
||||
function getRulesForCategory(category) {
|
||||
return ANTIPATTERNS.filter(rule => rule.category === category);
|
||||
}
|
||||
@@ -585,8 +610,10 @@ export {
|
||||
ANTIPATTERNS,
|
||||
RULE_SCOPES,
|
||||
RULE_ENGINE_SUPPORT,
|
||||
ADVISORY_RULE_IDS,
|
||||
getAntipattern,
|
||||
getRulesForCategory,
|
||||
getRuleEngineSupport,
|
||||
isAdvisoryRule,
|
||||
filterByScopes,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
BORDER_SAFE_TAGS,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
EM_DASH_FLOOR,
|
||||
GENERIC_FONTS,
|
||||
KNOWN_SERIF_FONTS,
|
||||
OVERUSED_FONTS,
|
||||
@@ -2594,6 +2596,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -2821,6 +2850,27 @@ function textDescendantsFlushSides(el, rect) {
|
||||
return flush;
|
||||
}
|
||||
|
||||
// Screen-reader-only ("visually hidden") text is exempt from the tiny-text
|
||||
// floors: it is never rendered, so its size is irrelevant. Detect the two
|
||||
// standard idioms — a known sr-only class on the element or an ancestor, and
|
||||
// the clip / 1px-box pattern. Works in both jsdom (declared styles) and the
|
||||
// browser (computed styles).
|
||||
const SR_ONLY_SELECTOR = '.sr-only, .visually-hidden, .visuallyhidden, .screen-reader, .screen-reader-only, .screenreader, .a11y-hidden, .hidden-visually, [class*="sr-only" i], [class*="visually-hidden" i], [class*="visuallyhidden" i], [class*="screen-reader" i], [class*="screenreader" i]';
|
||||
function isVisuallyHidden(el, style) {
|
||||
if ((el.matches && el.matches(SR_ONLY_SELECTOR)) || (el.closest && el.closest(SR_ONLY_SELECTOR))) return true;
|
||||
const pos = style.position || '';
|
||||
if (pos === 'absolute' || pos === 'fixed') {
|
||||
const clip = style.clip || '';
|
||||
const clipPath = style.clipPath || style.webkitClipPath || style['clip-path'] || '';
|
||||
if (/rect\(\s*0/.test(clip) || /inset\(\s*(?:50%|99|100%)/.test(clipPath)) return true;
|
||||
const w = parseFloat(style.width);
|
||||
const h = parseFloat(style.height);
|
||||
const overflow = style.overflow || '';
|
||||
if ((w === 1 || h === 1) && (overflow === 'hidden' || overflow === 'clip')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
||||
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
||||
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
||||
@@ -3105,6 +3155,60 @@ function checkQuality(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Undersized functional / UI text ---
|
||||
// Complements `tiny-text` above, which owns long body copy and deliberately
|
||||
// EXEMPTS the UI furniture layer (nav, footer, links, buttons, labels,
|
||||
// uppercase micro-labels). This rule targets exactly that blind spot: the
|
||||
// interactive and short content-bearing text — nav items, buttons, labels,
|
||||
// table cells, meta rows, timecodes — shipped below an 11px floor.
|
||||
//
|
||||
// The live failure it closes: a build shipped its entire furniture layer at
|
||||
// 8px, and the design hook waved it through because 8px had been added to
|
||||
// the DESIGN.md size ramp. Being on the ramp is a token argument, not a
|
||||
// legibility one, so this rule ignores the design system entirely — a value
|
||||
// on the ramp is still flagged.
|
||||
//
|
||||
// Floors: 11px for anything functional. The floor holds inside a footer;
|
||||
// only NON-interactive legal smallprint gets the softer 10px floor. Exempts
|
||||
// sup/sub, visually-hidden (sr-only) text, and code/terminal contexts.
|
||||
// Uppercase letterspaced micro-labels are still functional — not exempt.
|
||||
{
|
||||
const directText = [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
.map(n => n.textContent || '')
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const dtLen = directText.length;
|
||||
const UI_SKIP_TAGS = new Set(['sub', 'sup', 'script', 'style', 'title', 'option']);
|
||||
const notRendered = style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse';
|
||||
// jsdom resolves the parent chain in resolveFontSizePx, so em/rem/%-sized
|
||||
// text that computes at or above the floor never reaches here. The browser
|
||||
// adapter additionally catches values only resolvable with real layout
|
||||
// (e.g. viewport-relative units, cascade winners set in linked sheets).
|
||||
if (fontSize > 0 && fontSize < 11 && dtLen >= 2 && !UI_SKIP_TAGS.has(tag) && !notRendered) {
|
||||
const EXEMPT_CONTEXT = 'pre, code, kbd, samp, var, svg, [aria-hidden="true"], [class*="terminal" i], [class*="console" i], [class*="code" i], [class*="mock" i], [class*="editor" i], [class*="syntax" i], [class*="diff" i]';
|
||||
const isExemptContext = (el.matches && el.matches(EXEMPT_CONTEXT)) || (el.closest && el.closest(EXEMPT_CONTEXT));
|
||||
if (!isExemptContext && !isVisuallyHidden(el, style)) {
|
||||
const INTERACTIVE = 'a[href], button, summary, label, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="treeitem"], [tabindex]';
|
||||
const FURNITURE = 'nav, [role="navigation"], td, th, [role="gridcell"], [role="cell"], caption, figcaption, dt, dd, footer, [class*="meta" i], [class*="label" i], [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="kicker" i], [class*="eyebrow" i], [class*="breadcrumb" i], [class*="timestamp" i], [class*="category" i], [class*="caption" i], [class*="nav" i]';
|
||||
const SMALLPRINT = 'small, footer, [class*="legal" i], [class*="copyright" i], [class*="fineprint" i], [class*="fine-print" i], [class*="smallprint" i], [class*="small-print" i], [class*="disclaimer" i], [class*="disclosure" i], [class*="footnote" i]';
|
||||
const isInteractive = (el.matches && el.matches(INTERACTIVE)) || (el.closest && el.closest(INTERACTIVE));
|
||||
const isFurniture = (el.matches && el.matches(FURNITURE)) || (el.closest && el.closest(FURNITURE));
|
||||
const isSmallprint = (el.matches && el.matches(SMALLPRINT)) || (el.closest && el.closest(SMALLPRINT));
|
||||
const floor = (!isInteractive && isSmallprint) ? 10 : 11;
|
||||
// Fire on functional text only: interactive, structural furniture, or
|
||||
// any short (<=20-char) run — the label / meta / timecode shape. Long
|
||||
// non-furniture body copy stays with `tiny-text`, so the two rules
|
||||
// never double-flag the same element.
|
||||
if (fontSize < floor && (isInteractive || isFurniture || dtLen <= 20)) {
|
||||
const excerpt = directText.slice(0, 40);
|
||||
findings.push({ id: 'undersized-ui-text', snippet: `${fontSize}px functional text "${excerpt}" (below ${floor}px floor)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- All-caps body text ---
|
||||
if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
|
||||
if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
||||
@@ -5121,6 +5225,8 @@ export {
|
||||
checkNumberedSectionLabels,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkNumberedSectionLabelsDOM,
|
||||
checkEmDashOveruse,
|
||||
checkEmDashOveruseDOM,
|
||||
isRepeatedTextContainer,
|
||||
collectRepeatedContainerTextFindings,
|
||||
checkRepeatedContainerTextFromDoc,
|
||||
|
||||
@@ -68,6 +68,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -97,5 +106,7 @@ export {
|
||||
GENERIC_FONTS,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
KNOWN_SERIF_FONTS,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
function arg(name, fallback = null) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
@@ -20,6 +21,183 @@ function arg(name, fallback = null) {
|
||||
return v && !v.startsWith('--') ? v : fallback;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake mode (IMPECCABLE_IMAGE_GEN_FAKE=1)
|
||||
//
|
||||
// Deterministic offline stand-in for the OpenAI call: same prompt -> identical
|
||||
// bytes, no network, no key, cost line reads $0.00. Used by the new-work smoke
|
||||
// suite so the concept/serve-question/image chain can run without spend. The
|
||||
// output renders the prompt over a 2-3 color palette hashed from the prompt,
|
||||
// plus a "SYNTHETIC COMP" corner label. SVG carries the readable text; the
|
||||
// raster (.png/.webp/.jpg) fallback carries palette stripes and stows the
|
||||
// prompt + marker in a PNG tEXt chunk so downstream stays a valid image.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FNV-1a 32-bit: tiny, dependency-free, stable across runs and platforms.
|
||||
function hash32(str) {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function hslToRgb(hDeg, s, l) {
|
||||
const h = ((hDeg % 360) + 360) % 360 / 360;
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const hue = (t) => {
|
||||
let tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||
if (tt < 1 / 2) return q;
|
||||
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||
return p;
|
||||
};
|
||||
return [hue(h + 1 / 3), hue(h), hue(h - 1 / 3)].map((c) => Math.round(c * 255));
|
||||
}
|
||||
|
||||
const toHex = ([r, g, b]) =>
|
||||
'#' + [r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// Two or three deterministic swatches derived from the prompt hash. The band
|
||||
// count itself is prompt-derived, so different prompts differ in palette.
|
||||
function palette(prompt) {
|
||||
const h = hash32(prompt);
|
||||
const base = h % 360;
|
||||
const bands = 2 + (h >>> 9) % 2; // 2 or 3
|
||||
const spread = 40 + (h >>> 3) % 120;
|
||||
const out = [];
|
||||
for (let i = 0; i < bands; i++) {
|
||||
const hue = base + i * spread;
|
||||
const light = 0.32 + ((h >>> (i * 5)) % 40) / 100; // 0.32 - 0.71
|
||||
out.push(hslToRgb(hue, 0.55, light));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function svgFake(prompt, [w, h]) {
|
||||
const colors = palette(prompt).map(toHex);
|
||||
const stops = colors
|
||||
.map((c, i) => `<stop offset="${Math.round((i / (colors.length - 1)) * 100)}%" stop-color="${c}"/>`)
|
||||
.join('');
|
||||
// Greedy word wrap tuned to the canvas width so the prompt stays legible.
|
||||
const perLine = Math.max(12, Math.floor(w / 26));
|
||||
const words = String(prompt).replace(/\s+/g, ' ').trim().split(' ');
|
||||
const lines = [];
|
||||
let cur = '';
|
||||
for (const word of words) {
|
||||
if ((cur + ' ' + word).trim().length > perLine) {
|
||||
if (cur) lines.push(cur);
|
||||
cur = word;
|
||||
} else {
|
||||
cur = (cur + ' ' + word).trim();
|
||||
}
|
||||
if (lines.length >= 10) break;
|
||||
}
|
||||
if (cur && lines.length < 11) lines.push(cur);
|
||||
const escape = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||||
const fontSize = Math.round(w / 24);
|
||||
const startY = h / 2 - ((lines.length - 1) * fontSize * 1.3) / 2;
|
||||
const text = lines
|
||||
.map((line, i) => `<text x="${w / 2}" y="${Math.round(startY + i * fontSize * 1.3)}" font-family="Helvetica, Arial, sans-serif" font-size="${fontSize}" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">${escape(line)}</text>`)
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">${stops}</linearGradient></defs>
|
||||
<rect width="${w}" height="${h}" fill="url(#g)"/>
|
||||
<rect x="0" y="0" width="${w}" height="${h}" fill="#000000" fill-opacity="0.22"/>
|
||||
${text}
|
||||
<rect x="${w - Math.round(w / 4.2)}" y="${h - Math.round(h / 16)}" width="${Math.round(w / 4.2)}" height="${Math.round(h / 16)}" fill="#000000" fill-opacity="0.55"/>
|
||||
<text x="${w - Math.round(w / 8.4)}" y="${h - Math.round(h / 32)}" font-family="Helvetica, Arial, sans-serif" font-size="${Math.round(w / 60)}" letter-spacing="2" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">SYNTHETIC COMP</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
// Minimal valid PNG: palette stripes plus a tEXt chunk carrying the marker and
|
||||
// prompt, so a .png/.webp fake stays a decodable image and still contains the
|
||||
// "SYNTHETIC" bytes downstream tools look for.
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c ^= buf[i];
|
||||
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function pngChunk(type, data) {
|
||||
const typeBuf = Buffer.from(type, 'latin1');
|
||||
const body = Buffer.concat([typeBuf, data]);
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body), 0);
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
|
||||
function pngFake(prompt, [w, h]) {
|
||||
const colors = palette(prompt); // [[r,g,b], ...]
|
||||
const bandH = Math.ceil(h / colors.length);
|
||||
// Raw image: each scanline prefixed with a 0 filter byte, RGB pixels.
|
||||
const stride = w * 3;
|
||||
const raw = Buffer.alloc(h * (stride + 1));
|
||||
for (let y = 0; y < h; y++) {
|
||||
const rowStart = y * (stride + 1);
|
||||
raw[rowStart] = 0;
|
||||
const [r, g, b] = colors[Math.min(colors.length - 1, Math.floor(y / bandH))];
|
||||
for (let x = 0; x < w; x++) {
|
||||
const p = rowStart + 1 + x * 3;
|
||||
raw[p] = r;
|
||||
raw[p + 1] = g;
|
||||
raw[p + 2] = b;
|
||||
}
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0);
|
||||
ihdr.writeUInt32BE(h, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // color type: truecolor RGB
|
||||
const idat = zlib.deflateSync(raw, { level: 9 });
|
||||
const textData = Buffer.concat([
|
||||
Buffer.from('Comment', 'latin1'),
|
||||
Buffer.from([0]),
|
||||
Buffer.from(`SYNTHETIC COMP: ${String(prompt).replace(/\s+/g, ' ').trim()}`, 'latin1'),
|
||||
]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
pngChunk('IHDR', ihdr),
|
||||
pngChunk('tEXt', textData),
|
||||
pngChunk('IDAT', idat),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function parseSize(sizeStr) {
|
||||
const m = String(sizeStr).match(/^(\d+)x(\d+)$/);
|
||||
if (!m) return [1536, 1024];
|
||||
return [Number(m[1]), Number(m[2])];
|
||||
}
|
||||
|
||||
if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
|
||||
const fakePromptFile = arg('prompt-file');
|
||||
const fakePrompt = fakePromptFile ? fs.readFileSync(fakePromptFile, 'utf8') : arg('prompt');
|
||||
const fakeOut = arg('out');
|
||||
if (!fakePrompt || !fakeOut) {
|
||||
console.error('generate-image: --prompt (or --prompt-file) and --out are required.');
|
||||
process.exit(1);
|
||||
}
|
||||
const dims = parseSize(arg('size', '1536x1024'));
|
||||
const bytes = fakeOut.endsWith('.svg')
|
||||
? Buffer.from(svgFake(fakePrompt, dims), 'utf8')
|
||||
: pngFake(fakePrompt, dims);
|
||||
fs.writeFileSync(fakeOut, bytes);
|
||||
console.log(`IMAGE: ${fakeOut} (${dims[0]}x${dims[1]}, fake synthetic comp, $0.00, no API call)`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const key = process.env.OPENAI_API_KEY;
|
||||
if (!key) {
|
||||
console.error('generate-image: OPENAI_API_KEY is not set; use the harness-native image tool instead.');
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* touchFile(cache, sessionId, filePath)
|
||||
* suppressionNotice(filePath)
|
||||
* filterFindings(findings, content, ext, config)
|
||||
* ADVISORY_RULES / isAdvisoryFinding(finding)
|
||||
* IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness)
|
||||
* matchConfiguredExtension(filePath, extensions)
|
||||
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
||||
@@ -126,6 +127,26 @@ export const IMMEDIATE_TIER_RULES = new Set([
|
||||
'design-system-font-size',
|
||||
]);
|
||||
|
||||
// ── Advisory rules ────────────────────────────────────────────────────────
|
||||
// Advisory rules are opt-in noise: the CLI reports them in a separate section
|
||||
// and they never count as failures. The design hook skips them entirely by
|
||||
// default — in both the per-edit PostToolUse pass and the Stop deep pass — so
|
||||
// the agent is never nagged about a taste call a human might make on purpose.
|
||||
// A project opts back in with `.impeccable/config.json`:
|
||||
// { "detector": { "advisoryRules": "include" } }
|
||||
// This set is the hook's own copy of the registry's `advisory: true` rules,
|
||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
||||
// self-contained and testable without loading the detector. Keep it in sync
|
||||
// with the registry (cli/engine/registry/antipatterns.mjs).
|
||||
export const ADVISORY_RULES = new Set([
|
||||
'em-dash-overuse',
|
||||
]);
|
||||
|
||||
export function isAdvisoryFinding(finding) {
|
||||
const id = finding && normalizeIgnoreRule(finding.antipattern);
|
||||
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG = Object.freeze({
|
||||
enabled: true,
|
||||
quiet: false,
|
||||
@@ -136,6 +157,9 @@ export const DEFAULT_CONFIG = Object.freeze({
|
||||
ignoreValues: [],
|
||||
extensions: [],
|
||||
perEditRules: 'immediate',
|
||||
// Advisory rules are skipped unless a project sets detector.advisoryRules to
|
||||
// "include". See ADVISORY_RULES above.
|
||||
advisoryRules: 'exclude',
|
||||
// maxFileBytes: not every generated artifact lives under a path we can
|
||||
// recognize. Committed browser bundles and vendored detector copies sit
|
||||
// next to source and run 200KB+, while genuinely authored stylesheets in
|
||||
@@ -293,6 +317,11 @@ function cloneDefaultConfig() {
|
||||
|
||||
function applyDetectorConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
// `detector.advisoryRules: "include"` opts the hook into advisory rules
|
||||
// (em-dash overuse, etc.). Any other value keeps the default "exclude".
|
||||
if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
|
||||
config.advisoryRules = raw.advisoryRules;
|
||||
}
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
@@ -755,8 +784,12 @@ export function filterFindings(findings, _content, _ext, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config.ignoreValues || []);
|
||||
// Advisory rules are skipped by default so the hook never nags about them;
|
||||
// a project opts in with detector.advisoryRules: "include".
|
||||
const includeAdvisory = (config?.advisoryRules || DEFAULT_CONFIG.advisoryRules) === 'include';
|
||||
return findings.filter((f) => {
|
||||
if (!f || typeof f !== 'object') return false;
|
||||
if (!includeAdvisory && isAdvisoryFinding(f)) return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(f, ignoreValues)) return false;
|
||||
return true;
|
||||
|
||||
@@ -43,7 +43,7 @@ function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
@@ -71,6 +71,11 @@ function cloneRawDetectionConfig() {
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
// Advisory rules are opt-in for the design hook; the CLI carries the setting
|
||||
// so config round-trips (e.g. `impeccable hooks ignore-value`) preserve it.
|
||||
if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
|
||||
config.advisoryRules = raw.advisoryRules;
|
||||
}
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
@@ -151,6 +156,9 @@ function normalizeDetectionConfigForWrite(config) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.advisoryRules === 'include' || config?.advisoryRules === 'exclude') {
|
||||
out.advisoryRules = config.advisoryRules;
|
||||
}
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
* }, ...
|
||||
* ],
|
||||
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
|
||||
* "canon": true, // adds the quiet "Play it straight" standing exit
|
||||
* // (returns {"optionId":"canon"}); direction rounds only
|
||||
* "steer": true // adds a free-text steer field returned with any answer
|
||||
* }
|
||||
*
|
||||
@@ -93,6 +95,24 @@ if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) {
|
||||
}
|
||||
}
|
||||
|
||||
// Both answer channels (blocking stdout and --wait collection) print through
|
||||
// this: the ANSWER line, then a directive to open the chosen card's imagery
|
||||
// when it has any. The card viewing happens at the moment of choice, in the
|
||||
// working turn, because a build that never reopens the chosen world's board
|
||||
// and hero calibrates on nothing.
|
||||
function printAnswer(raw) {
|
||||
console.log(`ANSWER: ${raw}`);
|
||||
try {
|
||||
const a = JSON.parse(raw);
|
||||
if (a.hero || a.board) {
|
||||
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code (download local copies first when your harness only reads files); they set the craft bar the build must reach.");
|
||||
}
|
||||
if (a.optionId === 'canon') {
|
||||
console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.');
|
||||
}
|
||||
} catch { /* raw answer */ }
|
||||
}
|
||||
|
||||
const payloadPath = arg('payload');
|
||||
const timeoutSec = Number(arg('timeout', '900'));
|
||||
const portArg = Number(arg('port', '0'));
|
||||
@@ -109,9 +129,10 @@ if (hasFlag('schema')) {
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', body: 'Fused alternate.', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
],
|
||||
reroll: true,
|
||||
canon: true,
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" is reserved. hero/board accept URLs or local paths.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board accept URLs or local paths. canon adds a quiet standing "Play it straight" action for direction decisions: the user\'s explicit door to the category standard. Include it only for visual-direction rounds; never present canon as your own recommendation.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -144,7 +165,7 @@ if (hasFlag('wait')) {
|
||||
}
|
||||
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
|
||||
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
|
||||
console.log(`ANSWER: ${collected}`);
|
||||
printAnswer(collected);
|
||||
// A re-roll keeps the table open: the server stays alive awaiting --update,
|
||||
// so only the answer file is consumed. Terminal choices clean up fully.
|
||||
let isRerollAnswer = false;
|
||||
@@ -242,12 +263,12 @@ function page() {
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${option.heroSrc || option.boardSrc ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : ''}
|
||||
<div class="media">
|
||||
${option.heroSrc ? `<img src="${esc(option.heroSrc)}" alt="">` : '<div class="hero-blank"></div>'}
|
||||
<div class="chips">${option.heroSrc ? expandChip : ''}${option.boardSrc ? flipChip('Board') : ''}</div>
|
||||
</div>
|
||||
${option.heroSrc || option.boardSrc ? `<div class="media">
|
||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||
<div class="chips">${expandChip}${option.boardSrc && option.heroSrc ? flipChip('Board') : ''}</div>
|
||||
</div>` : ''}
|
||||
<div class="body">
|
||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||
<h2>${esc(option.label)}</h2>
|
||||
@@ -255,7 +276,7 @@ function page() {
|
||||
<button class="choose" data-id="${esc(option.id)}">Build this</button>
|
||||
</div>
|
||||
</div>
|
||||
${option.boardSrc ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
${option.boardSrc && option.heroSrc ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
<div class="media back-media">
|
||||
<img src="${esc(option.boardSrc)}" alt="">
|
||||
<div class="chips">${expandChip}${flipChip('Hero')}</div>
|
||||
@@ -328,6 +349,10 @@ function page() {
|
||||
.card:hover .face.lead { border-color: var(--ks-kinpaku); }
|
||||
@media (prefers-reduced-motion: reduce) { .card-inner { transition: none; } }
|
||||
.kicker { position: absolute; z-index: 2; top: 12px; left: 12px; padding: 4px 10px; background: var(--ks-kinpaku); color: var(--ks-dark-ink); font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .24em; text-transform: uppercase; border-radius: 4px; }
|
||||
/* Text-only card: a grounded direction with no rendered card drops the media
|
||||
region entirely instead of reserving a blank 16:9 void. */
|
||||
.face.text-only .kicker { position: static; align-self: flex-start; margin: 14px 0 0 14px; }
|
||||
.face.text-only .body { padding-top: 12px; }
|
||||
.media { position: relative; width: 100%; aspect-ratio: 16/9; flex: none; }
|
||||
.media img { width: 100%; height: 100%; object-fit: cover; display: block; background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
|
||||
.face.back { background: var(--ks-lacquer-deep); }
|
||||
@@ -350,6 +375,11 @@ function page() {
|
||||
#reroll { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
#reroll svg { width: 15px; height: 15px; }
|
||||
/* The quiet exit: always available, never argued with, visually subordinate
|
||||
to the dealt cards and the re-roll so it reads as the user's own door,
|
||||
not a recommendation. */
|
||||
#canon { align-self: center; padding: 0 4px; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: inherit; opacity: .45; background: transparent; border: none; border-bottom: 1px dotted currentColor; cursor: pointer; transition: opacity .2s ease; }
|
||||
#canon:hover { opacity: .85; }
|
||||
.card.skeleton .media { background: var(--ks-graphite); }
|
||||
.shimmer { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
|
||||
.card.skeleton .line { height: 11px; border-radius: 4px; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
|
||||
@@ -384,6 +414,7 @@ function page() {
|
||||
<footer>
|
||||
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
|
||||
${payload.reroll ? '<button id="reroll"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg><span>Re-roll</span></button>' : ''}
|
||||
${payload.canon ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
|
||||
</footer>
|
||||
<script>
|
||||
const steer = () => document.getElementById('steer')?.value || '';
|
||||
@@ -454,6 +485,7 @@ function page() {
|
||||
const closeLightbox = () => { lightbox.classList.remove('open'); setTimeout(() => { lightbox.hidden = true; }, 250); };
|
||||
lightbox.addEventListener('click', closeLightbox);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
||||
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
|
||||
document.getElementById('reroll')?.addEventListener('click', async () => {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
|
||||
const grid = document.querySelector('.grid');
|
||||
@@ -518,7 +550,11 @@ const server = http.createServer((req, res) => {
|
||||
if (imageMatch) {
|
||||
const abs = localImages[Number(imageMatch[1])];
|
||||
if (!abs) { res.writeHead(404); res.end(); return; }
|
||||
const type = abs.endsWith('.webp') ? 'image/webp' : abs.endsWith('.png') ? 'image/png' : 'image/jpeg';
|
||||
const type = abs.endsWith('.webp') ? 'image/webp'
|
||||
: abs.endsWith('.png') ? 'image/png'
|
||||
: abs.endsWith('.svg') ? 'image/svg+xml'
|
||||
: abs.endsWith('.gif') ? 'image/gif'
|
||||
: 'image/jpeg';
|
||||
res.writeHead(200, { 'content-type': type });
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
return;
|
||||
@@ -531,13 +567,18 @@ const server = http.createServer((req, res) => {
|
||||
res.end('{"ok":true}');
|
||||
let parsed = {};
|
||||
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
|
||||
const answer = JSON.stringify({ optionId: parsed.optionId ?? null, steer: parsed.steer ?? '' });
|
||||
const chosen = options.find((o) => o.id === parsed.optionId);
|
||||
const answer = JSON.stringify({
|
||||
optionId: parsed.optionId ?? null,
|
||||
steer: parsed.steer ?? '',
|
||||
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||
});
|
||||
const isReroll = parsed.optionId === 'reroll';
|
||||
if (detachedKey) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
|
||||
} else {
|
||||
console.log(`ANSWER: ${answer}`);
|
||||
printAnswer(answer);
|
||||
}
|
||||
// A re-roll in detached mode keeps the table open: the client shows a
|
||||
// loading hand and reloads when --update delivers the next round.
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \".agents/skills/impeccable/scripts/hook.mjs\"",
|
||||
"command": "[ ! -f \".agents/skills/impeccable/scripts/hook.mjs\" ] || node \".agents/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 5,
|
||||
"statusMessage": "Checking UI changes"
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \".agents/skills/impeccable/scripts/hook.mjs\"",
|
||||
"command": "[ ! -f \".agents/skills/impeccable/scripts/hook.mjs\" ] || node \".agents/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Design deep pass"
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"hooks": {
|
||||
"preToolUse": [
|
||||
{
|
||||
"command": "node \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\"",
|
||||
"command": "[ ! -f \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\" ] || node \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 4.0.0
|
||||
version: 4.0.1
|
||||
license: Apache 2.0
|
||||
---
|
||||
|
||||
|
||||
@@ -44,9 +44,11 @@ The script assigns which structure gets built: your top-ranked structure is what
|
||||
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, the notation, publications, identity programs, data graphics, and interfaces it reads daily, not only its physical objects; a nameable abstract system (a school of poster, a documentation standard, a data-graphic tradition) 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; the audience's world is larger than that, so 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 .cursor/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Offer re-roll with an optional one-line steer instead of a ranked menu; a lineup invites the safest card. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Pick the channel by capability, not by habit: can you put a page in front of the user, through an in-app browser or by opening a browser window on their machine outside your harness? If yes, present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll plus steer enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll, then run `node .cursor/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, preferring the in-app browser when the harness has one, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. If no, because the session is headless, CI, an eval worker, or a remote shell with no display, skip the page and put the same decision through the structured question tool; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the one or two fused challengers that survived the weighing as named alternates with a one-line case each, plus re-roll with an optional one-line steer. What you never present is a ranked menu of your own grounded candidates; a lineup of those invites the safest card, while dealt challengers carry no such rut. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list also carries the standing exit as its last option.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. When the harness can view images, open the QUALITY BAR board and hero the seed prints for the world you build (when it only reads local images, download the card to a temp file first and view that): they set the craft level the build must reach, the finish, commitment, and art direction of a rendered reference, and never dictate the composition; your surface serves this product.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Run `node .cursor/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images for exactly this reason (download to a temp file when the harness only reads local images). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
|
||||
|
||||
Every direction the roll can land on must already be viable: every relationship and claim it visualizes true, a real palette and component family, a distinctive composition with one product-specific experience, workable at full-surface scale within the available assets, tools, and performance budget. A candidate that fails on truth is replaced before the roll, never rescued by it. Truth binds claims, not demonstrations: in greenfield work, author whatever illustrative material the concept needs at full fidelity, label it synthetic wherever a visitor could mistake it for the real thing, and hand the user the list of what to replace with real material. What stays uninventable are commercial and factual claims: prices, customers, benchmarks, endpoints, capabilities the product does not have. Refusing a bold direction because its demonstration data does not exist yet is the timidity reflex wearing honesty's clothes.
|
||||
|
||||
@@ -62,10 +64,12 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su
|
||||
|
||||
## 5. Record the decision
|
||||
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. 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. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, the staging chosen for it, and the seed key the script printed; when the seed dealt stagings, choosing the world does not silently discharge the staging decision, so name the one you commit to. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
|
||||
When a new or replacement world is chosen, DESIGN.md is part of recording the decision, not an aftercare step: write it at the appropriate project or app boundary using [document.md](document.md) before the first build edit lands, in the same working stretch as the direction contract. Record only durable system rules; exact tokens may remain provisional until the first build establishes them, and you update the file when the build settles them. A new world shipped with no DESIGN.md is an incomplete run, exactly as a missing PRODUCT.md is; the finishing review checks the file exists and matches the built world. An ordinary extension does not rewrite DESIGN.md.
|
||||
|
||||
Two observed ways a DESIGN.md rule goes wrong: a prohibition that bans a device the world itself uses natively (arcade lettering lives on its offset shadow), and a token added so a hook finding stops firing. Check every prohibition against the world's own materials; a value earns its place by the world and by legibility, never by silencing a check.
|
||||
|
||||
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
|
||||
|
||||
`node .cursor/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
|
||||
@@ -74,7 +78,7 @@ If the work establishes durable strategy for a route or artifact, read its exist
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
Visualize before you build whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports: render the chosen direction as a design-system board and a first-surface mock, correct material drift between mock and intent, then build. Seeing the direction first measurably strengthens the result. [codex.md](codex.md) carries the deep native-generation flow; the mock is a selection aid, not authority.
|
||||
Whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options rendered and put before the user for approval. This step is proven to produce the most compositional and ambitious work.
|
||||
|
||||
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
|
||||
|
||||
@@ -84,8 +88,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie
|
||||
|
||||
- **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. 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; never invent prices, customers, benchmarks, or capabilities.
|
||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, titles, 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, and no section is omittable. When a commercial claim stayed unanswered, ship a clearly marked placeholder value and hand the user the replacement list; a section thinned or dropped for missing truth is the asset gap wearing honesty's clothes. Decoration compensating for missing content, gradients, glass, borders, icon tiles where an authored asset belongs, is the same gap wearing chrome's.
|
||||
- **Generate the imagery the build needs.** When any image generation is available, a harness-native tool or the API fallback context.mjs reports, generating the design's imagery is part of building: heroes, covers, demonstration thumbnails, textures, marks. Gray boxes and stock-styled placeholders are not a fallback while generation is one command away; state the cost once before the first render and batch what the surface needs.
|
||||
- **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 that before a line of code exists. 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.
|
||||
@@ -97,4 +100,4 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
||||
|
||||
Inspect desktop and mobile, critique the render against the user's request, the direction contract, and DESIGN.md, fix material gaps, and re-inspect. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary. After a first implementation of a new world, update DESIGN.md with the exact tokens and behaviors that survived the build.
|
||||
|
||||
When the harness can run a separate agent, this review belongs there, not in the build thread: give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, and existing hook findings. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector.
|
||||
Spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex), and give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, existing hook findings, and the QUALITY BAR card and approved comp paths. This review never runs inside the build thread; a harness with no subagents runs it as its own fresh pass, after stepping fully out of the build context. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Its second check is the ceiling: against the chosen world's QUALITY BAR card and the approved comp, name the native devices the build left unused, frame, depth, lettering, ornament density; floor rigor is very good at disguising an unreached card. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector.
|
||||
|
||||
+8
-8
@@ -1,15 +1,15 @@
|
||||
# Codex: Surface Probes & Asset Production
|
||||
# Visualize: Direction Comps & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) only when the harness has native image generation and a substantial, high-fidelity surface would benefit from seeing the shortlisted concept before code. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
Load this from [new-work.md](new-work.md) whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate the smallest useful probe set
|
||||
## Generate three compositional options
|
||||
|
||||
Generate one to three high-fidelity north-star comps using the native image-generation capability. Base them on the real content and the surface concepts already developed with the user.
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building.
|
||||
|
||||
- When the user shortlisted multiple concepts, show one clear expression of each.
|
||||
- When one concept is already selected, vary only the structural uncertainty that the image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||
- When the user shortlisted multiple concepts, spread the three across them.
|
||||
- When one direction is committed, vary the structural uncertainty an image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||
- Show enough beyond the opening moment to prove the concept can govern the whole requested surface.
|
||||
- Do not generate a palette artifact, ask new atmosphere questions, introduce a different type voice, or invent a new motif. If the committed world cannot support the concept, return to the concept shortlist rather than changing the world.
|
||||
|
||||
@@ -17,7 +17,7 @@ Treat each comp as a direction test, not a screenshot specification. Core UI tex
|
||||
|
||||
## One approval point
|
||||
|
||||
Show the probes together and ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
Show the three together: in the harness when it can display images, otherwise on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero). Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
@@ -33,6 +33,6 @@ Treat the comp as a north star, not something to trace. Do not rasterize core UI
|
||||
|
||||
## Produce only the assets the build needs
|
||||
|
||||
When clean raster ingredients are required and a scoped subagent is available and authorized, use `impeccable_asset_producer`. Give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets with the native image-generation capability in the current thread.
|
||||
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread with whatever generation exists, the native tool or generate-image.mjs.
|
||||
|
||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||
@@ -79,6 +79,13 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_DIR = process.env.IMPECCABLE_CATALOG_DIR || here;
|
||||
const API_BASE = (process.env.IMPECCABLE_API_URL || 'https://impeccable.style/api').replace(/\/$/, '');
|
||||
const API_TIMEOUT_MS = Number(process.env.IMPECCABLE_API_TIMEOUT || 4000);
|
||||
// All API calls in one seed run share a single deadline so an unreachable
|
||||
// network degrades after one timeout total, never one timeout per call.
|
||||
let apiDeadline = null;
|
||||
function apiBudgetMs() {
|
||||
if (apiDeadline === null) apiDeadline = Date.now() + API_TIMEOUT_MS;
|
||||
return Math.max(0, apiDeadline - Date.now());
|
||||
}
|
||||
|
||||
const localStates = new Map();
|
||||
function loadLocal(catalogDir = CATALOG_DIR) {
|
||||
@@ -120,9 +127,15 @@ async function fetchRoll({ scope, key, mode, reroll }) {
|
||||
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
|
||||
if (mode) params.set('mode', mode);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal });
|
||||
// Race the budget explicitly: abort signals do not reliably cancel the
|
||||
// TCP connect phase, so a blackholed route would otherwise stall ~10s.
|
||||
const response = await Promise.race([
|
||||
fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal }),
|
||||
new Promise(resolveTimeout => setTimeout(() => resolveTimeout(null), apiBudgetMs())),
|
||||
]);
|
||||
if (!response) return null;
|
||||
if (!response.ok) return null;
|
||||
const roll = await response.json();
|
||||
if (!Array.isArray(roll.challengers) || roll.challengers.length === 0) return null;
|
||||
@@ -143,7 +156,7 @@ function telemetryDisabled() {
|
||||
export async function pingChosen({ chosenId, key, scope, mode }) {
|
||||
if (telemetryDisabled() || !chosenId) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
await fetch(`${API_BASE}/chosen`, {
|
||||
method: 'POST',
|
||||
@@ -445,7 +458,10 @@ ASSIGNED INDEX: ${buildIndex}
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
No challengers this run: the roll service was unreachable and no local
|
||||
catalog exists. Proceed with the grounded candidates alone; the assignment
|
||||
catalog exists. A sandboxed shell without network egress is the most common
|
||||
cause: if this harness can rerun the command with network access granted,
|
||||
do that once before proceeding. Otherwise proceed with the grounded
|
||||
candidates alone; the assignment
|
||||
above still applies at full strength. Tell the user plainly that this roll
|
||||
ran degraded, with no challengers and no quality-bar boards; do not present
|
||||
the outcome as a full roll. A degraded roll changes the cards, not the
|
||||
@@ -543,4 +559,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
// A raced-out fetch may still hold a socket; exit explicitly so the CLI
|
||||
// never lingers on a dead network path after output is written.
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
|
||||
@@ -1223,6 +1223,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -1541,6 +1545,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadDesignSystemForCwd } from '../design-system.mjs';
|
||||
import { loadDesignSystemForTarget } from '../design-system.mjs';
|
||||
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs';
|
||||
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
|
||||
import { detectHtml } from '../engines/static-html/detect-html.mjs';
|
||||
@@ -27,9 +28,37 @@ function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
// Local filesystem path behind a file:// URL, or null when it can't be mapped.
|
||||
function fileUrlToLocalPath(url) {
|
||||
try {
|
||||
return fileURLToPath(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Advisory findings are detected but never treated as failures: they list in a
|
||||
// separate, visually dimmed section, are excluded from the failure count that
|
||||
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
|
||||
// filter. Every advisory finding carries the flag (stamped by the registry via
|
||||
// findings.mjs).
|
||||
function isAdvisory(finding) {
|
||||
return finding && finding.advisory === true;
|
||||
}
|
||||
|
||||
function partitionAdvisory(findings) {
|
||||
const primary = [];
|
||||
const advisory = [];
|
||||
for (const f of findings) (isAdvisory(f) ? advisory : primary).push(f);
|
||||
return { primary, advisory };
|
||||
}
|
||||
|
||||
// ANSI dim, when stderr is a TTY. Advisory output is chrome, so keep it quiet.
|
||||
function dim(text) {
|
||||
return process.stderr.isTTY ? `\x1b[2m${text}\x1b[0m` : text;
|
||||
}
|
||||
|
||||
function formatFindingsBody(findings) {
|
||||
const grouped = {};
|
||||
for (const f of findings) {
|
||||
if (!grouped[f.file]) grouped[f.file] = [];
|
||||
@@ -44,7 +73,28 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatAdvisorySection(advisory) {
|
||||
if (!advisory || advisory.length === 0) return '';
|
||||
const lines = [`\n${dim('── Advisory (not counted as failures) ──')}`];
|
||||
for (const line of formatFindingsBody(advisory)) lines.push(dim(line));
|
||||
lines.push(dim(`\n${advisory.length} advisory note${advisory.length === 1 ? '' : 's'}. Suppress with --no-advisory.`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Text/JSON formatter. `findings` is the full set; advisory items are separated
|
||||
// out into their own section and excluded from the failure summary count. JSON
|
||||
// output keeps every finding (each advisory one flagged) in a single array.
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
const { primary, advisory } = partitionAdvisory(findings);
|
||||
const out = [...formatFindingsBody(primary)];
|
||||
out.push(`\n${formatFindingSummary(primary.length)}`);
|
||||
const advisorySection = formatAdvisorySection(advisory);
|
||||
if (advisorySection) out.push(advisorySection);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -52,7 +102,11 @@ function formatFindings(findings, jsonMode) {
|
||||
// Stdin handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleStdin(options = {}) {
|
||||
// `optionsFor` maps a local path to scan options carrying that path's own
|
||||
// project design system (or base options when null). Falls back to a plain
|
||||
// object so direct/legacy callers still work.
|
||||
async function handleStdin(optionsFor = () => ({})) {
|
||||
const resolve = typeof optionsFor === 'function' ? optionsFor : () => optionsFor;
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const input = Buffer.concat(chunks).toString('utf-8');
|
||||
@@ -60,11 +114,12 @@ async function handleStdin(options = {}) {
|
||||
const parsed = JSON.parse(input);
|
||||
const fp = parsed?.tool_input?.file_path;
|
||||
if (fp && fs.existsSync(fp)) {
|
||||
const options = resolve(fp);
|
||||
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
|
||||
? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options);
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return detectText(input, '<stdin>', options);
|
||||
return detectText(input, '<stdin>', resolve(null));
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +155,14 @@ Options:
|
||||
ignore comments, or DESIGN.md
|
||||
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
|
||||
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
|
||||
--no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)
|
||||
--help Show this help message
|
||||
|
||||
Advisory findings:
|
||||
Some rules are advisory: detected and listed in a separate section, but never
|
||||
counted as failures and never changing the exit code. They stay out of the
|
||||
failure count so they never block automation. --no-advisory hides them.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -139,6 +200,7 @@ async function detectCli() {
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
const noAdvisory = args.includes('--no-advisory');
|
||||
// --fast (regex-only) is deprecated: since the jsdom removal, the static
|
||||
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
|
||||
// only loses coverage for no real speed win. Accept the flag for back-compat
|
||||
@@ -199,14 +261,23 @@ async function detectCli() {
|
||||
process.exit(1);
|
||||
}
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
|
||||
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
|
||||
// apply by default. `--no-config` (raw scan) and the dedicated
|
||||
// `--no-inline-ignores` both turn them off.
|
||||
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
|
||||
const scanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
if (viewport) scanOptions.viewport = viewport;
|
||||
const baseScanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (viewport) baseScanOptions.viewport = viewport;
|
||||
// DESIGN.md must resolve from EACH scan target's own project root, not from
|
||||
// process.cwd(): scanning project B's files from inside project A applied A's
|
||||
// design rules (cross-project contamination). Resolve per target, memoized by
|
||||
// resolved project root so a multi-file scan pays the read once per project.
|
||||
// A target with no project marker above it gets no design system (never cwd's).
|
||||
const designSystemCache = new Map();
|
||||
const scanOptionsFor = (localPath) => {
|
||||
if (!designSystemEnabled || !localPath) return baseScanOptions;
|
||||
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
||||
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
|
||||
};
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -214,7 +285,7 @@ async function detectCli() {
|
||||
let allFindings = [];
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptions);
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
} else {
|
||||
const paths = targets.length > 0 ? targets : [process.cwd()];
|
||||
// file:// URLs get the same Puppeteer-rendered pass as http(s) — the
|
||||
@@ -228,10 +299,17 @@ async function detectCli() {
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (urlRe.test(target)) {
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
// process.cwd()'s.
|
||||
const urlOptions = /^file:/i.test(target)
|
||||
? scanOptionsFor(fileUrlToLocalPath(target))
|
||||
: baseScanOptions;
|
||||
try {
|
||||
const scanner = browserDetector
|
||||
? (url) => browserDetector.detectUrl(url, scanOptions)
|
||||
: (url) => detectUrl(url, scanOptions);
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
@@ -297,11 +375,14 @@ async function detectCli() {
|
||||
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
let fileFindings;
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
fileFindings = await detectHtml(file, scanOptions);
|
||||
fileFindings = await detectHtml(file, fileOptions);
|
||||
} else {
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions);
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, fileOptions);
|
||||
}
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
@@ -316,10 +397,11 @@ async function detectCli() {
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
allFindings.push(...await detectHtml(resolved, scanOptions));
|
||||
allFindings.push(...await detectHtml(resolved, fileOptions));
|
||||
} else {
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions));
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,12 +412,24 @@ async function detectCli() {
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
// --no-advisory drops advisory findings before any output or exit-code math.
|
||||
if (noAdvisory) allFindings = allFindings.filter((f) => !isAdvisory(f));
|
||||
|
||||
// The exit code and failure count reflect non-advisory findings only. An
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else if (quietMode) {
|
||||
process.stderr.write(formatFindingSummary(primary.length) + '\n');
|
||||
if (advisory.length > 0) {
|
||||
process.stderr.write(dim(`${advisory.length} advisory note${advisory.length === 1 ? '' : 's'} (not counted).`) + '\n');
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { finding } from './findings.mjs';
|
||||
@@ -7,6 +8,11 @@ import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs';
|
||||
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// Files/dirs whose presence marks a directory as a project root. Mirrors the
|
||||
// walk-up semantics of skill/scripts/context.mjs (`resolveProject`), which the
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
@@ -469,6 +475,62 @@ function loadDesignSystemForCwd(cwd = process.cwd()) {
|
||||
});
|
||||
}
|
||||
|
||||
// Directory to begin the project-root walk from, given a scan target that may
|
||||
// be a file or a directory (and may not exist yet).
|
||||
function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
return fs.statSync(abs).isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
// Nonexistent path: treat an extension-bearing leaf as a file.
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
// Returns { dir, hasDesign } for the stopping directory, or null when the walk
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the design system that governs a specific scan target, by walking up
|
||||
// from the target's own location — never process.cwd(). Scanning project B's
|
||||
// files from inside project A applies B's DESIGN.md (or none), not A's.
|
||||
//
|
||||
// Pass a `cache` Map to memoize by resolved design root across a multi-file
|
||||
// scan; a target with no design root above it resolves to null.
|
||||
export function loadDesignSystemForTarget(targetPath, { cache, cwd = process.cwd() } = {}) {
|
||||
const startDir = designSystemStartDir(targetPath, cwd);
|
||||
const found = findDesignRoot(startDir);
|
||||
const key = found ? `root:${found.dir}` : '\0none';
|
||||
if (cache && cache.has(key)) return cache.get(key);
|
||||
const loaded = found?.hasDesign ? loadDesignSystemForCwd(found.dir) : null;
|
||||
if (cache) cache.set(key, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function isAllowedFont(font, designSystem) {
|
||||
if (!font || GENERIC_FONTS.has(font)) return true;
|
||||
if (!designSystem?.hasFonts) return true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS, EM_DASH_FLOOR, EM_DASH_CHARS_PER_DASH } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
@@ -16,6 +16,7 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
|
||||
const hasBorderRadius = (line) => /border-radius/i.test(line);
|
||||
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
|
||||
|
||||
|
||||
/** Strip HTML to plain text — drops script/style/comments/tags so
|
||||
* content-text analyzers don't false-positive on code or CSS. */
|
||||
function stripHtmlToText(html) {
|
||||
@@ -306,15 +307,34 @@ const REGEX_ANALYZERS = [
|
||||
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
||||
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
|
||||
},
|
||||
// Em-dash overuse: 5+ em-dashes or "--" in body text content
|
||||
// (occasional em-dash use in prose is fine; the pattern fires only
|
||||
// when count crosses into AI-cadence territory).
|
||||
// Em-dash overuse (ADVISORY): the AI cadence tell is em-dash *saturation*,
|
||||
// not the occasional dash. Humans use em-dashes legitimately, so this rule is
|
||||
// advisory (surfaced separately, never a failure, hook-skipped by default) and
|
||||
// its threshold is deliberately conservative. Two gates must both hold:
|
||||
// 1. Absolute floor of EM_DASH_FLOOR (8) dashes — a page with a handful
|
||||
// never fires, no matter how short.
|
||||
// 2. Density: at least one dash per EM_DASH_CHARS_PER_DASH (500) characters
|
||||
// of body text, so a long article that uses eight across several thousand
|
||||
// words is left alone while a short, dash-per-clause landing page is not.
|
||||
// Raised from the old flat 5-dash floor, which fired on ordinary long prose.
|
||||
//
|
||||
// stripHtmlToText drops tags but leaves character-entity escapes intact, so
|
||||
// a model that writes `—`, `—`, or `—` renders an em-dash
|
||||
// the counter never saw. Decode the em-dash entities (named, zero-padded
|
||||
// decimal, upper/lower hex) to the literal glyph first. En-dash entities are
|
||||
// deliberately left alone: the rule counts em-dashes, and the literal `–`
|
||||
// was never counted either.
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
const text = stripHtmlToText(content)
|
||||
.replace(/—|�*8212;|�*2014;/gi, '—');
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(text) !== null) count++;
|
||||
if (count < 5) return [];
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
// Saturation gate: dashes must be dense in the prose, not sprinkled through
|
||||
// a long document. textLength <= count * chars-per-dash means the density is
|
||||
// at or above the threshold.
|
||||
if (text.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
|
||||
},
|
||||
// Marketing buzzwords: SaaS phrase list
|
||||
|
||||
@@ -6,7 +6,13 @@ function getAP(id) {
|
||||
|
||||
function finding(id, filePath, snippet, line = 0) {
|
||||
const ap = getAP(id);
|
||||
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
// Advisory findings are detected but reported separately and never counted as
|
||||
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
|
||||
// can partition without a registry lookup. Only stamped when true to keep the
|
||||
// finding shape stable for the vast majority of rules.
|
||||
if (ap.advisory === true) base.advisory = true;
|
||||
return base;
|
||||
}
|
||||
|
||||
export { getAP, finding };
|
||||
|
||||
@@ -213,9 +213,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -405,6 +410,14 @@ const ANTIPATTERNS = [
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
},
|
||||
{
|
||||
id: 'undersized-ui-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Undersized functional text',
|
||||
description:
|
||||
'Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.',
|
||||
},
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
@@ -556,6 +569,18 @@ function getAntipattern(id) {
|
||||
return ANTIPATTERNS.find(rule => rule.id === id);
|
||||
}
|
||||
|
||||
// Advisory rules are detected and reported, but never treated as failures:
|
||||
// the CLI lists them under a separate "Advisory" section, they do not affect
|
||||
// exit codes or the failure count, and the design hook skips them by default.
|
||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
||||
const ADVISORY_RULE_IDS = new Set(
|
||||
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
|
||||
);
|
||||
|
||||
function isAdvisoryRule(id) {
|
||||
return ADVISORY_RULE_IDS.has(id);
|
||||
}
|
||||
|
||||
function getRulesForCategory(category) {
|
||||
return ANTIPATTERNS.filter(rule => rule.category === category);
|
||||
}
|
||||
@@ -585,8 +610,10 @@ export {
|
||||
ANTIPATTERNS,
|
||||
RULE_SCOPES,
|
||||
RULE_ENGINE_SUPPORT,
|
||||
ADVISORY_RULE_IDS,
|
||||
getAntipattern,
|
||||
getRulesForCategory,
|
||||
getRuleEngineSupport,
|
||||
isAdvisoryRule,
|
||||
filterByScopes,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
BORDER_SAFE_TAGS,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
EM_DASH_FLOOR,
|
||||
GENERIC_FONTS,
|
||||
KNOWN_SERIF_FONTS,
|
||||
OVERUSED_FONTS,
|
||||
@@ -2594,6 +2596,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -2821,6 +2850,27 @@ function textDescendantsFlushSides(el, rect) {
|
||||
return flush;
|
||||
}
|
||||
|
||||
// Screen-reader-only ("visually hidden") text is exempt from the tiny-text
|
||||
// floors: it is never rendered, so its size is irrelevant. Detect the two
|
||||
// standard idioms — a known sr-only class on the element or an ancestor, and
|
||||
// the clip / 1px-box pattern. Works in both jsdom (declared styles) and the
|
||||
// browser (computed styles).
|
||||
const SR_ONLY_SELECTOR = '.sr-only, .visually-hidden, .visuallyhidden, .screen-reader, .screen-reader-only, .screenreader, .a11y-hidden, .hidden-visually, [class*="sr-only" i], [class*="visually-hidden" i], [class*="visuallyhidden" i], [class*="screen-reader" i], [class*="screenreader" i]';
|
||||
function isVisuallyHidden(el, style) {
|
||||
if ((el.matches && el.matches(SR_ONLY_SELECTOR)) || (el.closest && el.closest(SR_ONLY_SELECTOR))) return true;
|
||||
const pos = style.position || '';
|
||||
if (pos === 'absolute' || pos === 'fixed') {
|
||||
const clip = style.clip || '';
|
||||
const clipPath = style.clipPath || style.webkitClipPath || style['clip-path'] || '';
|
||||
if (/rect\(\s*0/.test(clip) || /inset\(\s*(?:50%|99|100%)/.test(clipPath)) return true;
|
||||
const w = parseFloat(style.width);
|
||||
const h = parseFloat(style.height);
|
||||
const overflow = style.overflow || '';
|
||||
if ((w === 1 || h === 1) && (overflow === 'hidden' || overflow === 'clip')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
||||
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
||||
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
||||
@@ -3105,6 +3155,60 @@ function checkQuality(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Undersized functional / UI text ---
|
||||
// Complements `tiny-text` above, which owns long body copy and deliberately
|
||||
// EXEMPTS the UI furniture layer (nav, footer, links, buttons, labels,
|
||||
// uppercase micro-labels). This rule targets exactly that blind spot: the
|
||||
// interactive and short content-bearing text — nav items, buttons, labels,
|
||||
// table cells, meta rows, timecodes — shipped below an 11px floor.
|
||||
//
|
||||
// The live failure it closes: a build shipped its entire furniture layer at
|
||||
// 8px, and the design hook waved it through because 8px had been added to
|
||||
// the DESIGN.md size ramp. Being on the ramp is a token argument, not a
|
||||
// legibility one, so this rule ignores the design system entirely — a value
|
||||
// on the ramp is still flagged.
|
||||
//
|
||||
// Floors: 11px for anything functional. The floor holds inside a footer;
|
||||
// only NON-interactive legal smallprint gets the softer 10px floor. Exempts
|
||||
// sup/sub, visually-hidden (sr-only) text, and code/terminal contexts.
|
||||
// Uppercase letterspaced micro-labels are still functional — not exempt.
|
||||
{
|
||||
const directText = [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
.map(n => n.textContent || '')
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const dtLen = directText.length;
|
||||
const UI_SKIP_TAGS = new Set(['sub', 'sup', 'script', 'style', 'title', 'option']);
|
||||
const notRendered = style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse';
|
||||
// jsdom resolves the parent chain in resolveFontSizePx, so em/rem/%-sized
|
||||
// text that computes at or above the floor never reaches here. The browser
|
||||
// adapter additionally catches values only resolvable with real layout
|
||||
// (e.g. viewport-relative units, cascade winners set in linked sheets).
|
||||
if (fontSize > 0 && fontSize < 11 && dtLen >= 2 && !UI_SKIP_TAGS.has(tag) && !notRendered) {
|
||||
const EXEMPT_CONTEXT = 'pre, code, kbd, samp, var, svg, [aria-hidden="true"], [class*="terminal" i], [class*="console" i], [class*="code" i], [class*="mock" i], [class*="editor" i], [class*="syntax" i], [class*="diff" i]';
|
||||
const isExemptContext = (el.matches && el.matches(EXEMPT_CONTEXT)) || (el.closest && el.closest(EXEMPT_CONTEXT));
|
||||
if (!isExemptContext && !isVisuallyHidden(el, style)) {
|
||||
const INTERACTIVE = 'a[href], button, summary, label, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="treeitem"], [tabindex]';
|
||||
const FURNITURE = 'nav, [role="navigation"], td, th, [role="gridcell"], [role="cell"], caption, figcaption, dt, dd, footer, [class*="meta" i], [class*="label" i], [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="kicker" i], [class*="eyebrow" i], [class*="breadcrumb" i], [class*="timestamp" i], [class*="category" i], [class*="caption" i], [class*="nav" i]';
|
||||
const SMALLPRINT = 'small, footer, [class*="legal" i], [class*="copyright" i], [class*="fineprint" i], [class*="fine-print" i], [class*="smallprint" i], [class*="small-print" i], [class*="disclaimer" i], [class*="disclosure" i], [class*="footnote" i]';
|
||||
const isInteractive = (el.matches && el.matches(INTERACTIVE)) || (el.closest && el.closest(INTERACTIVE));
|
||||
const isFurniture = (el.matches && el.matches(FURNITURE)) || (el.closest && el.closest(FURNITURE));
|
||||
const isSmallprint = (el.matches && el.matches(SMALLPRINT)) || (el.closest && el.closest(SMALLPRINT));
|
||||
const floor = (!isInteractive && isSmallprint) ? 10 : 11;
|
||||
// Fire on functional text only: interactive, structural furniture, or
|
||||
// any short (<=20-char) run — the label / meta / timecode shape. Long
|
||||
// non-furniture body copy stays with `tiny-text`, so the two rules
|
||||
// never double-flag the same element.
|
||||
if (fontSize < floor && (isInteractive || isFurniture || dtLen <= 20)) {
|
||||
const excerpt = directText.slice(0, 40);
|
||||
findings.push({ id: 'undersized-ui-text', snippet: `${fontSize}px functional text "${excerpt}" (below ${floor}px floor)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- All-caps body text ---
|
||||
if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
|
||||
if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
||||
@@ -5121,6 +5225,8 @@ export {
|
||||
checkNumberedSectionLabels,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkNumberedSectionLabelsDOM,
|
||||
checkEmDashOveruse,
|
||||
checkEmDashOveruseDOM,
|
||||
isRepeatedTextContainer,
|
||||
collectRepeatedContainerTextFindings,
|
||||
checkRepeatedContainerTextFromDoc,
|
||||
|
||||
@@ -68,6 +68,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -97,5 +106,7 @@ export {
|
||||
GENERIC_FONTS,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
KNOWN_SERIF_FONTS,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
function arg(name, fallback = null) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
@@ -20,6 +21,183 @@ function arg(name, fallback = null) {
|
||||
return v && !v.startsWith('--') ? v : fallback;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake mode (IMPECCABLE_IMAGE_GEN_FAKE=1)
|
||||
//
|
||||
// Deterministic offline stand-in for the OpenAI call: same prompt -> identical
|
||||
// bytes, no network, no key, cost line reads $0.00. Used by the new-work smoke
|
||||
// suite so the concept/serve-question/image chain can run without spend. The
|
||||
// output renders the prompt over a 2-3 color palette hashed from the prompt,
|
||||
// plus a "SYNTHETIC COMP" corner label. SVG carries the readable text; the
|
||||
// raster (.png/.webp/.jpg) fallback carries palette stripes and stows the
|
||||
// prompt + marker in a PNG tEXt chunk so downstream stays a valid image.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FNV-1a 32-bit: tiny, dependency-free, stable across runs and platforms.
|
||||
function hash32(str) {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function hslToRgb(hDeg, s, l) {
|
||||
const h = ((hDeg % 360) + 360) % 360 / 360;
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const hue = (t) => {
|
||||
let tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||
if (tt < 1 / 2) return q;
|
||||
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||
return p;
|
||||
};
|
||||
return [hue(h + 1 / 3), hue(h), hue(h - 1 / 3)].map((c) => Math.round(c * 255));
|
||||
}
|
||||
|
||||
const toHex = ([r, g, b]) =>
|
||||
'#' + [r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// Two or three deterministic swatches derived from the prompt hash. The band
|
||||
// count itself is prompt-derived, so different prompts differ in palette.
|
||||
function palette(prompt) {
|
||||
const h = hash32(prompt);
|
||||
const base = h % 360;
|
||||
const bands = 2 + (h >>> 9) % 2; // 2 or 3
|
||||
const spread = 40 + (h >>> 3) % 120;
|
||||
const out = [];
|
||||
for (let i = 0; i < bands; i++) {
|
||||
const hue = base + i * spread;
|
||||
const light = 0.32 + ((h >>> (i * 5)) % 40) / 100; // 0.32 - 0.71
|
||||
out.push(hslToRgb(hue, 0.55, light));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function svgFake(prompt, [w, h]) {
|
||||
const colors = palette(prompt).map(toHex);
|
||||
const stops = colors
|
||||
.map((c, i) => `<stop offset="${Math.round((i / (colors.length - 1)) * 100)}%" stop-color="${c}"/>`)
|
||||
.join('');
|
||||
// Greedy word wrap tuned to the canvas width so the prompt stays legible.
|
||||
const perLine = Math.max(12, Math.floor(w / 26));
|
||||
const words = String(prompt).replace(/\s+/g, ' ').trim().split(' ');
|
||||
const lines = [];
|
||||
let cur = '';
|
||||
for (const word of words) {
|
||||
if ((cur + ' ' + word).trim().length > perLine) {
|
||||
if (cur) lines.push(cur);
|
||||
cur = word;
|
||||
} else {
|
||||
cur = (cur + ' ' + word).trim();
|
||||
}
|
||||
if (lines.length >= 10) break;
|
||||
}
|
||||
if (cur && lines.length < 11) lines.push(cur);
|
||||
const escape = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||||
const fontSize = Math.round(w / 24);
|
||||
const startY = h / 2 - ((lines.length - 1) * fontSize * 1.3) / 2;
|
||||
const text = lines
|
||||
.map((line, i) => `<text x="${w / 2}" y="${Math.round(startY + i * fontSize * 1.3)}" font-family="Helvetica, Arial, sans-serif" font-size="${fontSize}" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">${escape(line)}</text>`)
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">${stops}</linearGradient></defs>
|
||||
<rect width="${w}" height="${h}" fill="url(#g)"/>
|
||||
<rect x="0" y="0" width="${w}" height="${h}" fill="#000000" fill-opacity="0.22"/>
|
||||
${text}
|
||||
<rect x="${w - Math.round(w / 4.2)}" y="${h - Math.round(h / 16)}" width="${Math.round(w / 4.2)}" height="${Math.round(h / 16)}" fill="#000000" fill-opacity="0.55"/>
|
||||
<text x="${w - Math.round(w / 8.4)}" y="${h - Math.round(h / 32)}" font-family="Helvetica, Arial, sans-serif" font-size="${Math.round(w / 60)}" letter-spacing="2" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">SYNTHETIC COMP</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
// Minimal valid PNG: palette stripes plus a tEXt chunk carrying the marker and
|
||||
// prompt, so a .png/.webp fake stays a decodable image and still contains the
|
||||
// "SYNTHETIC" bytes downstream tools look for.
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c ^= buf[i];
|
||||
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function pngChunk(type, data) {
|
||||
const typeBuf = Buffer.from(type, 'latin1');
|
||||
const body = Buffer.concat([typeBuf, data]);
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body), 0);
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
|
||||
function pngFake(prompt, [w, h]) {
|
||||
const colors = palette(prompt); // [[r,g,b], ...]
|
||||
const bandH = Math.ceil(h / colors.length);
|
||||
// Raw image: each scanline prefixed with a 0 filter byte, RGB pixels.
|
||||
const stride = w * 3;
|
||||
const raw = Buffer.alloc(h * (stride + 1));
|
||||
for (let y = 0; y < h; y++) {
|
||||
const rowStart = y * (stride + 1);
|
||||
raw[rowStart] = 0;
|
||||
const [r, g, b] = colors[Math.min(colors.length - 1, Math.floor(y / bandH))];
|
||||
for (let x = 0; x < w; x++) {
|
||||
const p = rowStart + 1 + x * 3;
|
||||
raw[p] = r;
|
||||
raw[p + 1] = g;
|
||||
raw[p + 2] = b;
|
||||
}
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0);
|
||||
ihdr.writeUInt32BE(h, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // color type: truecolor RGB
|
||||
const idat = zlib.deflateSync(raw, { level: 9 });
|
||||
const textData = Buffer.concat([
|
||||
Buffer.from('Comment', 'latin1'),
|
||||
Buffer.from([0]),
|
||||
Buffer.from(`SYNTHETIC COMP: ${String(prompt).replace(/\s+/g, ' ').trim()}`, 'latin1'),
|
||||
]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
pngChunk('IHDR', ihdr),
|
||||
pngChunk('tEXt', textData),
|
||||
pngChunk('IDAT', idat),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function parseSize(sizeStr) {
|
||||
const m = String(sizeStr).match(/^(\d+)x(\d+)$/);
|
||||
if (!m) return [1536, 1024];
|
||||
return [Number(m[1]), Number(m[2])];
|
||||
}
|
||||
|
||||
if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
|
||||
const fakePromptFile = arg('prompt-file');
|
||||
const fakePrompt = fakePromptFile ? fs.readFileSync(fakePromptFile, 'utf8') : arg('prompt');
|
||||
const fakeOut = arg('out');
|
||||
if (!fakePrompt || !fakeOut) {
|
||||
console.error('generate-image: --prompt (or --prompt-file) and --out are required.');
|
||||
process.exit(1);
|
||||
}
|
||||
const dims = parseSize(arg('size', '1536x1024'));
|
||||
const bytes = fakeOut.endsWith('.svg')
|
||||
? Buffer.from(svgFake(fakePrompt, dims), 'utf8')
|
||||
: pngFake(fakePrompt, dims);
|
||||
fs.writeFileSync(fakeOut, bytes);
|
||||
console.log(`IMAGE: ${fakeOut} (${dims[0]}x${dims[1]}, fake synthetic comp, $0.00, no API call)`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const key = process.env.OPENAI_API_KEY;
|
||||
if (!key) {
|
||||
console.error('generate-image: OPENAI_API_KEY is not set; use the harness-native image tool instead.');
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* touchFile(cache, sessionId, filePath)
|
||||
* suppressionNotice(filePath)
|
||||
* filterFindings(findings, content, ext, config)
|
||||
* ADVISORY_RULES / isAdvisoryFinding(finding)
|
||||
* IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness)
|
||||
* matchConfiguredExtension(filePath, extensions)
|
||||
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
||||
@@ -126,6 +127,26 @@ export const IMMEDIATE_TIER_RULES = new Set([
|
||||
'design-system-font-size',
|
||||
]);
|
||||
|
||||
// ── Advisory rules ────────────────────────────────────────────────────────
|
||||
// Advisory rules are opt-in noise: the CLI reports them in a separate section
|
||||
// and they never count as failures. The design hook skips them entirely by
|
||||
// default — in both the per-edit PostToolUse pass and the Stop deep pass — so
|
||||
// the agent is never nagged about a taste call a human might make on purpose.
|
||||
// A project opts back in with `.impeccable/config.json`:
|
||||
// { "detector": { "advisoryRules": "include" } }
|
||||
// This set is the hook's own copy of the registry's `advisory: true` rules,
|
||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
||||
// self-contained and testable without loading the detector. Keep it in sync
|
||||
// with the registry (cli/engine/registry/antipatterns.mjs).
|
||||
export const ADVISORY_RULES = new Set([
|
||||
'em-dash-overuse',
|
||||
]);
|
||||
|
||||
export function isAdvisoryFinding(finding) {
|
||||
const id = finding && normalizeIgnoreRule(finding.antipattern);
|
||||
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG = Object.freeze({
|
||||
enabled: true,
|
||||
quiet: false,
|
||||
@@ -136,6 +157,9 @@ export const DEFAULT_CONFIG = Object.freeze({
|
||||
ignoreValues: [],
|
||||
extensions: [],
|
||||
perEditRules: 'immediate',
|
||||
// Advisory rules are skipped unless a project sets detector.advisoryRules to
|
||||
// "include". See ADVISORY_RULES above.
|
||||
advisoryRules: 'exclude',
|
||||
// maxFileBytes: not every generated artifact lives under a path we can
|
||||
// recognize. Committed browser bundles and vendored detector copies sit
|
||||
// next to source and run 200KB+, while genuinely authored stylesheets in
|
||||
@@ -293,6 +317,11 @@ function cloneDefaultConfig() {
|
||||
|
||||
function applyDetectorConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
// `detector.advisoryRules: "include"` opts the hook into advisory rules
|
||||
// (em-dash overuse, etc.). Any other value keeps the default "exclude".
|
||||
if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
|
||||
config.advisoryRules = raw.advisoryRules;
|
||||
}
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
@@ -755,8 +784,12 @@ export function filterFindings(findings, _content, _ext, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config.ignoreValues || []);
|
||||
// Advisory rules are skipped by default so the hook never nags about them;
|
||||
// a project opts in with detector.advisoryRules: "include".
|
||||
const includeAdvisory = (config?.advisoryRules || DEFAULT_CONFIG.advisoryRules) === 'include';
|
||||
return findings.filter((f) => {
|
||||
if (!f || typeof f !== 'object') return false;
|
||||
if (!includeAdvisory && isAdvisoryFinding(f)) return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(f, ignoreValues)) return false;
|
||||
return true;
|
||||
|
||||
@@ -43,7 +43,7 @@ function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
@@ -71,6 +71,11 @@ function cloneRawDetectionConfig() {
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
// Advisory rules are opt-in for the design hook; the CLI carries the setting
|
||||
// so config round-trips (e.g. `impeccable hooks ignore-value`) preserve it.
|
||||
if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
|
||||
config.advisoryRules = raw.advisoryRules;
|
||||
}
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
@@ -151,6 +156,9 @@ function normalizeDetectionConfigForWrite(config) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.advisoryRules === 'include' || config?.advisoryRules === 'exclude') {
|
||||
out.advisoryRules = config.advisoryRules;
|
||||
}
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
* }, ...
|
||||
* ],
|
||||
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
|
||||
* "canon": true, // adds the quiet "Play it straight" standing exit
|
||||
* // (returns {"optionId":"canon"}); direction rounds only
|
||||
* "steer": true // adds a free-text steer field returned with any answer
|
||||
* }
|
||||
*
|
||||
@@ -93,6 +95,24 @@ if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) {
|
||||
}
|
||||
}
|
||||
|
||||
// Both answer channels (blocking stdout and --wait collection) print through
|
||||
// this: the ANSWER line, then a directive to open the chosen card's imagery
|
||||
// when it has any. The card viewing happens at the moment of choice, in the
|
||||
// working turn, because a build that never reopens the chosen world's board
|
||||
// and hero calibrates on nothing.
|
||||
function printAnswer(raw) {
|
||||
console.log(`ANSWER: ${raw}`);
|
||||
try {
|
||||
const a = JSON.parse(raw);
|
||||
if (a.hero || a.board) {
|
||||
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code (download local copies first when your harness only reads files); they set the craft bar the build must reach.");
|
||||
}
|
||||
if (a.optionId === 'canon') {
|
||||
console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.');
|
||||
}
|
||||
} catch { /* raw answer */ }
|
||||
}
|
||||
|
||||
const payloadPath = arg('payload');
|
||||
const timeoutSec = Number(arg('timeout', '900'));
|
||||
const portArg = Number(arg('port', '0'));
|
||||
@@ -109,9 +129,10 @@ if (hasFlag('schema')) {
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', body: 'Fused alternate.', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
],
|
||||
reroll: true,
|
||||
canon: true,
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" is reserved. hero/board accept URLs or local paths.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board accept URLs or local paths. canon adds a quiet standing "Play it straight" action for direction decisions: the user\'s explicit door to the category standard. Include it only for visual-direction rounds; never present canon as your own recommendation.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -144,7 +165,7 @@ if (hasFlag('wait')) {
|
||||
}
|
||||
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
|
||||
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
|
||||
console.log(`ANSWER: ${collected}`);
|
||||
printAnswer(collected);
|
||||
// A re-roll keeps the table open: the server stays alive awaiting --update,
|
||||
// so only the answer file is consumed. Terminal choices clean up fully.
|
||||
let isRerollAnswer = false;
|
||||
@@ -242,12 +263,12 @@ function page() {
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${option.heroSrc || option.boardSrc ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : ''}
|
||||
<div class="media">
|
||||
${option.heroSrc ? `<img src="${esc(option.heroSrc)}" alt="">` : '<div class="hero-blank"></div>'}
|
||||
<div class="chips">${option.heroSrc ? expandChip : ''}${option.boardSrc ? flipChip('Board') : ''}</div>
|
||||
</div>
|
||||
${option.heroSrc || option.boardSrc ? `<div class="media">
|
||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||
<div class="chips">${expandChip}${option.boardSrc && option.heroSrc ? flipChip('Board') : ''}</div>
|
||||
</div>` : ''}
|
||||
<div class="body">
|
||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||
<h2>${esc(option.label)}</h2>
|
||||
@@ -255,7 +276,7 @@ function page() {
|
||||
<button class="choose" data-id="${esc(option.id)}">Build this</button>
|
||||
</div>
|
||||
</div>
|
||||
${option.boardSrc ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
${option.boardSrc && option.heroSrc ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
<div class="media back-media">
|
||||
<img src="${esc(option.boardSrc)}" alt="">
|
||||
<div class="chips">${expandChip}${flipChip('Hero')}</div>
|
||||
@@ -328,6 +349,10 @@ function page() {
|
||||
.card:hover .face.lead { border-color: var(--ks-kinpaku); }
|
||||
@media (prefers-reduced-motion: reduce) { .card-inner { transition: none; } }
|
||||
.kicker { position: absolute; z-index: 2; top: 12px; left: 12px; padding: 4px 10px; background: var(--ks-kinpaku); color: var(--ks-dark-ink); font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .24em; text-transform: uppercase; border-radius: 4px; }
|
||||
/* Text-only card: a grounded direction with no rendered card drops the media
|
||||
region entirely instead of reserving a blank 16:9 void. */
|
||||
.face.text-only .kicker { position: static; align-self: flex-start; margin: 14px 0 0 14px; }
|
||||
.face.text-only .body { padding-top: 12px; }
|
||||
.media { position: relative; width: 100%; aspect-ratio: 16/9; flex: none; }
|
||||
.media img { width: 100%; height: 100%; object-fit: cover; display: block; background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
|
||||
.face.back { background: var(--ks-lacquer-deep); }
|
||||
@@ -350,6 +375,11 @@ function page() {
|
||||
#reroll { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
#reroll svg { width: 15px; height: 15px; }
|
||||
/* The quiet exit: always available, never argued with, visually subordinate
|
||||
to the dealt cards and the re-roll so it reads as the user's own door,
|
||||
not a recommendation. */
|
||||
#canon { align-self: center; padding: 0 4px; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: inherit; opacity: .45; background: transparent; border: none; border-bottom: 1px dotted currentColor; cursor: pointer; transition: opacity .2s ease; }
|
||||
#canon:hover { opacity: .85; }
|
||||
.card.skeleton .media { background: var(--ks-graphite); }
|
||||
.shimmer { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
|
||||
.card.skeleton .line { height: 11px; border-radius: 4px; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
|
||||
@@ -384,6 +414,7 @@ function page() {
|
||||
<footer>
|
||||
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
|
||||
${payload.reroll ? '<button id="reroll"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg><span>Re-roll</span></button>' : ''}
|
||||
${payload.canon ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
|
||||
</footer>
|
||||
<script>
|
||||
const steer = () => document.getElementById('steer')?.value || '';
|
||||
@@ -454,6 +485,7 @@ function page() {
|
||||
const closeLightbox = () => { lightbox.classList.remove('open'); setTimeout(() => { lightbox.hidden = true; }, 250); };
|
||||
lightbox.addEventListener('click', closeLightbox);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
||||
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
|
||||
document.getElementById('reroll')?.addEventListener('click', async () => {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
|
||||
const grid = document.querySelector('.grid');
|
||||
@@ -518,7 +550,11 @@ const server = http.createServer((req, res) => {
|
||||
if (imageMatch) {
|
||||
const abs = localImages[Number(imageMatch[1])];
|
||||
if (!abs) { res.writeHead(404); res.end(); return; }
|
||||
const type = abs.endsWith('.webp') ? 'image/webp' : abs.endsWith('.png') ? 'image/png' : 'image/jpeg';
|
||||
const type = abs.endsWith('.webp') ? 'image/webp'
|
||||
: abs.endsWith('.png') ? 'image/png'
|
||||
: abs.endsWith('.svg') ? 'image/svg+xml'
|
||||
: abs.endsWith('.gif') ? 'image/gif'
|
||||
: 'image/jpeg';
|
||||
res.writeHead(200, { 'content-type': type });
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
return;
|
||||
@@ -531,13 +567,18 @@ const server = http.createServer((req, res) => {
|
||||
res.end('{"ok":true}');
|
||||
let parsed = {};
|
||||
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
|
||||
const answer = JSON.stringify({ optionId: parsed.optionId ?? null, steer: parsed.steer ?? '' });
|
||||
const chosen = options.find((o) => o.id === parsed.optionId);
|
||||
const answer = JSON.stringify({
|
||||
optionId: parsed.optionId ?? null,
|
||||
steer: parsed.steer ?? '',
|
||||
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||
});
|
||||
const isReroll = parsed.optionId === 'reroll';
|
||||
if (detachedKey) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
|
||||
} else {
|
||||
console.log(`ANSWER: ${answer}`);
|
||||
printAnswer(answer);
|
||||
}
|
||||
// A re-roll in detached mode keeps the table open: the client shows a
|
||||
// loading hand and reloads when --update delivers the next round.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 4.0.0
|
||||
version: 4.0.1
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
@@ -44,9 +44,11 @@ The script assigns which structure gets built: your top-ranked structure is what
|
||||
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, the notation, publications, identity programs, data graphics, and interfaces it reads daily, not only its physical objects; a nameable abstract system (a school of poster, a documentation standard, a data-graphic tradition) 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; the audience's world is larger than that, so 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 .gemini/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Offer re-roll with an optional one-line steer instead of a ranked menu; a lineup invites the safest card. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Pick the channel by capability, not by habit: can you put a page in front of the user, through an in-app browser or by opening a browser window on their machine outside your harness? If yes, present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll plus steer enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll, then run `node .gemini/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, preferring the in-app browser when the harness has one, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. If no, because the session is headless, CI, an eval worker, or a remote shell with no display, skip the page and put the same decision through the structured question tool; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the one or two fused challengers that survived the weighing as named alternates with a one-line case each, plus re-roll with an optional one-line steer. What you never present is a ranked menu of your own grounded candidates; a lineup of those invites the safest card, while dealt challengers carry no such rut. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list also carries the standing exit as its last option.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. When the harness can view images, open the QUALITY BAR board and hero the seed prints for the world you build (when it only reads local images, download the card to a temp file first and view that): they set the craft level the build must reach, the finish, commitment, and art direction of a rendered reference, and never dictate the composition; your surface serves this product.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Run `node .gemini/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images for exactly this reason (download to a temp file when the harness only reads local images). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
|
||||
|
||||
Every direction the roll can land on must already be viable: every relationship and claim it visualizes true, a real palette and component family, a distinctive composition with one product-specific experience, workable at full-surface scale within the available assets, tools, and performance budget. A candidate that fails on truth is replaced before the roll, never rescued by it. Truth binds claims, not demonstrations: in greenfield work, author whatever illustrative material the concept needs at full fidelity, label it synthetic wherever a visitor could mistake it for the real thing, and hand the user the list of what to replace with real material. What stays uninventable are commercial and factual claims: prices, customers, benchmarks, endpoints, capabilities the product does not have. Refusing a bold direction because its demonstration data does not exist yet is the timidity reflex wearing honesty's clothes.
|
||||
|
||||
@@ -62,10 +64,12 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su
|
||||
|
||||
## 5. Record the decision
|
||||
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. 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. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, the staging chosen for it, and the seed key the script printed; when the seed dealt stagings, choosing the world does not silently discharge the staging decision, so name the one you commit to. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
|
||||
When a new or replacement world is chosen, DESIGN.md is part of recording the decision, not an aftercare step: write it at the appropriate project or app boundary using [document.md](document.md) before the first build edit lands, in the same working stretch as the direction contract. Record only durable system rules; exact tokens may remain provisional until the first build establishes them, and you update the file when the build settles them. A new world shipped with no DESIGN.md is an incomplete run, exactly as a missing PRODUCT.md is; the finishing review checks the file exists and matches the built world. An ordinary extension does not rewrite DESIGN.md.
|
||||
|
||||
Two observed ways a DESIGN.md rule goes wrong: a prohibition that bans a device the world itself uses natively (arcade lettering lives on its offset shadow), and a token added so a hook finding stops firing. Check every prohibition against the world's own materials; a value earns its place by the world and by legibility, never by silencing a check.
|
||||
|
||||
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
|
||||
|
||||
`node .gemini/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
|
||||
@@ -74,7 +78,7 @@ If the work establishes durable strategy for a route or artifact, read its exist
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
Visualize before you build whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports: render the chosen direction as a design-system board and a first-surface mock, correct material drift between mock and intent, then build. Seeing the direction first measurably strengthens the result. [codex.md](codex.md) carries the deep native-generation flow; the mock is a selection aid, not authority.
|
||||
Whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options rendered and put before the user for approval. This step is proven to produce the most compositional and ambitious work.
|
||||
|
||||
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
|
||||
|
||||
@@ -84,8 +88,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie
|
||||
|
||||
- **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. 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; never invent prices, customers, benchmarks, or capabilities.
|
||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, titles, 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, and no section is omittable. When a commercial claim stayed unanswered, ship a clearly marked placeholder value and hand the user the replacement list; a section thinned or dropped for missing truth is the asset gap wearing honesty's clothes. Decoration compensating for missing content, gradients, glass, borders, icon tiles where an authored asset belongs, is the same gap wearing chrome's.
|
||||
- **Generate the imagery the build needs.** When any image generation is available, a harness-native tool or the API fallback context.mjs reports, generating the design's imagery is part of building: heroes, covers, demonstration thumbnails, textures, marks. Gray boxes and stock-styled placeholders are not a fallback while generation is one command away; state the cost once before the first render and batch what the surface needs.
|
||||
- **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 that before a line of code exists. 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.
|
||||
@@ -97,4 +100,4 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
||||
|
||||
Inspect desktop and mobile, critique the render against the user's request, the direction contract, and DESIGN.md, fix material gaps, and re-inspect. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary. After a first implementation of a new world, update DESIGN.md with the exact tokens and behaviors that survived the build.
|
||||
|
||||
When the harness can run a separate agent, this review belongs there, not in the build thread: give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, and existing hook findings. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector.
|
||||
Spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex), and give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, existing hook findings, and the QUALITY BAR card and approved comp paths. This review never runs inside the build thread; a harness with no subagents runs it as its own fresh pass, after stepping fully out of the build context. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Its second check is the ceiling: against the chosen world's QUALITY BAR card and the approved comp, name the native devices the build left unused, frame, depth, lettering, ornament density; floor rigor is very good at disguising an unreached card. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector.
|
||||
|
||||
+8
-8
@@ -1,15 +1,15 @@
|
||||
# Codex: Surface Probes & Asset Production
|
||||
# Visualize: Direction Comps & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) only when the harness has native image generation and a substantial, high-fidelity surface would benefit from seeing the shortlisted concept before code. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
Load this from [new-work.md](new-work.md) whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate the smallest useful probe set
|
||||
## Generate three compositional options
|
||||
|
||||
Generate one to three high-fidelity north-star comps using the native image-generation capability. Base them on the real content and the surface concepts already developed with the user.
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building.
|
||||
|
||||
- When the user shortlisted multiple concepts, show one clear expression of each.
|
||||
- When one concept is already selected, vary only the structural uncertainty that the image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||
- When the user shortlisted multiple concepts, spread the three across them.
|
||||
- When one direction is committed, vary the structural uncertainty an image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||
- Show enough beyond the opening moment to prove the concept can govern the whole requested surface.
|
||||
- Do not generate a palette artifact, ask new atmosphere questions, introduce a different type voice, or invent a new motif. If the committed world cannot support the concept, return to the concept shortlist rather than changing the world.
|
||||
|
||||
@@ -17,7 +17,7 @@ Treat each comp as a direction test, not a screenshot specification. Core UI tex
|
||||
|
||||
## One approval point
|
||||
|
||||
Show the probes together and ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
Show the three together: in the harness when it can display images, otherwise on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero). Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
@@ -33,6 +33,6 @@ Treat the comp as a north star, not something to trace. Do not rasterize core UI
|
||||
|
||||
## Produce only the assets the build needs
|
||||
|
||||
When clean raster ingredients are required and a scoped subagent is available and authorized, use `impeccable_asset_producer`. Give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets with the native image-generation capability in the current thread.
|
||||
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread with whatever generation exists, the native tool or generate-image.mjs.
|
||||
|
||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||
@@ -79,6 +79,13 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_DIR = process.env.IMPECCABLE_CATALOG_DIR || here;
|
||||
const API_BASE = (process.env.IMPECCABLE_API_URL || 'https://impeccable.style/api').replace(/\/$/, '');
|
||||
const API_TIMEOUT_MS = Number(process.env.IMPECCABLE_API_TIMEOUT || 4000);
|
||||
// All API calls in one seed run share a single deadline so an unreachable
|
||||
// network degrades after one timeout total, never one timeout per call.
|
||||
let apiDeadline = null;
|
||||
function apiBudgetMs() {
|
||||
if (apiDeadline === null) apiDeadline = Date.now() + API_TIMEOUT_MS;
|
||||
return Math.max(0, apiDeadline - Date.now());
|
||||
}
|
||||
|
||||
const localStates = new Map();
|
||||
function loadLocal(catalogDir = CATALOG_DIR) {
|
||||
@@ -120,9 +127,15 @@ async function fetchRoll({ scope, key, mode, reroll }) {
|
||||
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
|
||||
if (mode) params.set('mode', mode);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal });
|
||||
// Race the budget explicitly: abort signals do not reliably cancel the
|
||||
// TCP connect phase, so a blackholed route would otherwise stall ~10s.
|
||||
const response = await Promise.race([
|
||||
fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal }),
|
||||
new Promise(resolveTimeout => setTimeout(() => resolveTimeout(null), apiBudgetMs())),
|
||||
]);
|
||||
if (!response) return null;
|
||||
if (!response.ok) return null;
|
||||
const roll = await response.json();
|
||||
if (!Array.isArray(roll.challengers) || roll.challengers.length === 0) return null;
|
||||
@@ -143,7 +156,7 @@ function telemetryDisabled() {
|
||||
export async function pingChosen({ chosenId, key, scope, mode }) {
|
||||
if (telemetryDisabled() || !chosenId) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
await fetch(`${API_BASE}/chosen`, {
|
||||
method: 'POST',
|
||||
@@ -445,7 +458,10 @@ ASSIGNED INDEX: ${buildIndex}
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
No challengers this run: the roll service was unreachable and no local
|
||||
catalog exists. Proceed with the grounded candidates alone; the assignment
|
||||
catalog exists. A sandboxed shell without network egress is the most common
|
||||
cause: if this harness can rerun the command with network access granted,
|
||||
do that once before proceeding. Otherwise proceed with the grounded
|
||||
candidates alone; the assignment
|
||||
above still applies at full strength. Tell the user plainly that this roll
|
||||
ran degraded, with no challengers and no quality-bar boards; do not present
|
||||
the outcome as a full roll. A degraded roll changes the cards, not the
|
||||
@@ -543,4 +559,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
// A raced-out fetch may still hold a socket; exit explicitly so the CLI
|
||||
// never lingers on a dead network path after output is written.
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
|
||||
@@ -1223,6 +1223,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -1541,6 +1545,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadDesignSystemForCwd } from '../design-system.mjs';
|
||||
import { loadDesignSystemForTarget } from '../design-system.mjs';
|
||||
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs';
|
||||
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
|
||||
import { detectHtml } from '../engines/static-html/detect-html.mjs';
|
||||
@@ -27,9 +28,37 @@ function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
// Local filesystem path behind a file:// URL, or null when it can't be mapped.
|
||||
function fileUrlToLocalPath(url) {
|
||||
try {
|
||||
return fileURLToPath(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Advisory findings are detected but never treated as failures: they list in a
|
||||
// separate, visually dimmed section, are excluded from the failure count that
|
||||
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
|
||||
// filter. Every advisory finding carries the flag (stamped by the registry via
|
||||
// findings.mjs).
|
||||
function isAdvisory(finding) {
|
||||
return finding && finding.advisory === true;
|
||||
}
|
||||
|
||||
function partitionAdvisory(findings) {
|
||||
const primary = [];
|
||||
const advisory = [];
|
||||
for (const f of findings) (isAdvisory(f) ? advisory : primary).push(f);
|
||||
return { primary, advisory };
|
||||
}
|
||||
|
||||
// ANSI dim, when stderr is a TTY. Advisory output is chrome, so keep it quiet.
|
||||
function dim(text) {
|
||||
return process.stderr.isTTY ? `\x1b[2m${text}\x1b[0m` : text;
|
||||
}
|
||||
|
||||
function formatFindingsBody(findings) {
|
||||
const grouped = {};
|
||||
for (const f of findings) {
|
||||
if (!grouped[f.file]) grouped[f.file] = [];
|
||||
@@ -44,7 +73,28 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatAdvisorySection(advisory) {
|
||||
if (!advisory || advisory.length === 0) return '';
|
||||
const lines = [`\n${dim('── Advisory (not counted as failures) ──')}`];
|
||||
for (const line of formatFindingsBody(advisory)) lines.push(dim(line));
|
||||
lines.push(dim(`\n${advisory.length} advisory note${advisory.length === 1 ? '' : 's'}. Suppress with --no-advisory.`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Text/JSON formatter. `findings` is the full set; advisory items are separated
|
||||
// out into their own section and excluded from the failure summary count. JSON
|
||||
// output keeps every finding (each advisory one flagged) in a single array.
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
const { primary, advisory } = partitionAdvisory(findings);
|
||||
const out = [...formatFindingsBody(primary)];
|
||||
out.push(`\n${formatFindingSummary(primary.length)}`);
|
||||
const advisorySection = formatAdvisorySection(advisory);
|
||||
if (advisorySection) out.push(advisorySection);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -52,7 +102,11 @@ function formatFindings(findings, jsonMode) {
|
||||
// Stdin handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleStdin(options = {}) {
|
||||
// `optionsFor` maps a local path to scan options carrying that path's own
|
||||
// project design system (or base options when null). Falls back to a plain
|
||||
// object so direct/legacy callers still work.
|
||||
async function handleStdin(optionsFor = () => ({})) {
|
||||
const resolve = typeof optionsFor === 'function' ? optionsFor : () => optionsFor;
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const input = Buffer.concat(chunks).toString('utf-8');
|
||||
@@ -60,11 +114,12 @@ async function handleStdin(options = {}) {
|
||||
const parsed = JSON.parse(input);
|
||||
const fp = parsed?.tool_input?.file_path;
|
||||
if (fp && fs.existsSync(fp)) {
|
||||
const options = resolve(fp);
|
||||
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
|
||||
? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options);
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return detectText(input, '<stdin>', options);
|
||||
return detectText(input, '<stdin>', resolve(null));
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +155,14 @@ Options:
|
||||
ignore comments, or DESIGN.md
|
||||
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
|
||||
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
|
||||
--no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)
|
||||
--help Show this help message
|
||||
|
||||
Advisory findings:
|
||||
Some rules are advisory: detected and listed in a separate section, but never
|
||||
counted as failures and never changing the exit code. They stay out of the
|
||||
failure count so they never block automation. --no-advisory hides them.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -139,6 +200,7 @@ async function detectCli() {
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
const noAdvisory = args.includes('--no-advisory');
|
||||
// --fast (regex-only) is deprecated: since the jsdom removal, the static
|
||||
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
|
||||
// only loses coverage for no real speed win. Accept the flag for back-compat
|
||||
@@ -199,14 +261,23 @@ async function detectCli() {
|
||||
process.exit(1);
|
||||
}
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
|
||||
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
|
||||
// apply by default. `--no-config` (raw scan) and the dedicated
|
||||
// `--no-inline-ignores` both turn them off.
|
||||
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
|
||||
const scanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
if (viewport) scanOptions.viewport = viewport;
|
||||
const baseScanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (viewport) baseScanOptions.viewport = viewport;
|
||||
// DESIGN.md must resolve from EACH scan target's own project root, not from
|
||||
// process.cwd(): scanning project B's files from inside project A applied A's
|
||||
// design rules (cross-project contamination). Resolve per target, memoized by
|
||||
// resolved project root so a multi-file scan pays the read once per project.
|
||||
// A target with no project marker above it gets no design system (never cwd's).
|
||||
const designSystemCache = new Map();
|
||||
const scanOptionsFor = (localPath) => {
|
||||
if (!designSystemEnabled || !localPath) return baseScanOptions;
|
||||
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
||||
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
|
||||
};
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -214,7 +285,7 @@ async function detectCli() {
|
||||
let allFindings = [];
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptions);
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
} else {
|
||||
const paths = targets.length > 0 ? targets : [process.cwd()];
|
||||
// file:// URLs get the same Puppeteer-rendered pass as http(s) — the
|
||||
@@ -228,10 +299,17 @@ async function detectCli() {
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (urlRe.test(target)) {
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
// process.cwd()'s.
|
||||
const urlOptions = /^file:/i.test(target)
|
||||
? scanOptionsFor(fileUrlToLocalPath(target))
|
||||
: baseScanOptions;
|
||||
try {
|
||||
const scanner = browserDetector
|
||||
? (url) => browserDetector.detectUrl(url, scanOptions)
|
||||
: (url) => detectUrl(url, scanOptions);
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
@@ -297,11 +375,14 @@ async function detectCli() {
|
||||
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
let fileFindings;
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
fileFindings = await detectHtml(file, scanOptions);
|
||||
fileFindings = await detectHtml(file, fileOptions);
|
||||
} else {
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions);
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, fileOptions);
|
||||
}
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
@@ -316,10 +397,11 @@ async function detectCli() {
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
allFindings.push(...await detectHtml(resolved, scanOptions));
|
||||
allFindings.push(...await detectHtml(resolved, fileOptions));
|
||||
} else {
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions));
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,12 +412,24 @@ async function detectCli() {
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
// --no-advisory drops advisory findings before any output or exit-code math.
|
||||
if (noAdvisory) allFindings = allFindings.filter((f) => !isAdvisory(f));
|
||||
|
||||
// The exit code and failure count reflect non-advisory findings only. An
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else if (quietMode) {
|
||||
process.stderr.write(formatFindingSummary(primary.length) + '\n');
|
||||
if (advisory.length > 0) {
|
||||
process.stderr.write(dim(`${advisory.length} advisory note${advisory.length === 1 ? '' : 's'} (not counted).`) + '\n');
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { finding } from './findings.mjs';
|
||||
@@ -7,6 +8,11 @@ import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs';
|
||||
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// Files/dirs whose presence marks a directory as a project root. Mirrors the
|
||||
// walk-up semantics of skill/scripts/context.mjs (`resolveProject`), which the
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
@@ -469,6 +475,62 @@ function loadDesignSystemForCwd(cwd = process.cwd()) {
|
||||
});
|
||||
}
|
||||
|
||||
// Directory to begin the project-root walk from, given a scan target that may
|
||||
// be a file or a directory (and may not exist yet).
|
||||
function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
return fs.statSync(abs).isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
// Nonexistent path: treat an extension-bearing leaf as a file.
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
// Returns { dir, hasDesign } for the stopping directory, or null when the walk
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the design system that governs a specific scan target, by walking up
|
||||
// from the target's own location — never process.cwd(). Scanning project B's
|
||||
// files from inside project A applies B's DESIGN.md (or none), not A's.
|
||||
//
|
||||
// Pass a `cache` Map to memoize by resolved design root across a multi-file
|
||||
// scan; a target with no design root above it resolves to null.
|
||||
export function loadDesignSystemForTarget(targetPath, { cache, cwd = process.cwd() } = {}) {
|
||||
const startDir = designSystemStartDir(targetPath, cwd);
|
||||
const found = findDesignRoot(startDir);
|
||||
const key = found ? `root:${found.dir}` : '\0none';
|
||||
if (cache && cache.has(key)) return cache.get(key);
|
||||
const loaded = found?.hasDesign ? loadDesignSystemForCwd(found.dir) : null;
|
||||
if (cache) cache.set(key, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function isAllowedFont(font, designSystem) {
|
||||
if (!font || GENERIC_FONTS.has(font)) return true;
|
||||
if (!designSystem?.hasFonts) return true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS, EM_DASH_FLOOR, EM_DASH_CHARS_PER_DASH } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
@@ -16,6 +16,7 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
|
||||
const hasBorderRadius = (line) => /border-radius/i.test(line);
|
||||
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
|
||||
|
||||
|
||||
/** Strip HTML to plain text — drops script/style/comments/tags so
|
||||
* content-text analyzers don't false-positive on code or CSS. */
|
||||
function stripHtmlToText(html) {
|
||||
@@ -306,15 +307,34 @@ const REGEX_ANALYZERS = [
|
||||
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
||||
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
|
||||
},
|
||||
// Em-dash overuse: 5+ em-dashes or "--" in body text content
|
||||
// (occasional em-dash use in prose is fine; the pattern fires only
|
||||
// when count crosses into AI-cadence territory).
|
||||
// Em-dash overuse (ADVISORY): the AI cadence tell is em-dash *saturation*,
|
||||
// not the occasional dash. Humans use em-dashes legitimately, so this rule is
|
||||
// advisory (surfaced separately, never a failure, hook-skipped by default) and
|
||||
// its threshold is deliberately conservative. Two gates must both hold:
|
||||
// 1. Absolute floor of EM_DASH_FLOOR (8) dashes — a page with a handful
|
||||
// never fires, no matter how short.
|
||||
// 2. Density: at least one dash per EM_DASH_CHARS_PER_DASH (500) characters
|
||||
// of body text, so a long article that uses eight across several thousand
|
||||
// words is left alone while a short, dash-per-clause landing page is not.
|
||||
// Raised from the old flat 5-dash floor, which fired on ordinary long prose.
|
||||
//
|
||||
// stripHtmlToText drops tags but leaves character-entity escapes intact, so
|
||||
// a model that writes `—`, `—`, or `—` renders an em-dash
|
||||
// the counter never saw. Decode the em-dash entities (named, zero-padded
|
||||
// decimal, upper/lower hex) to the literal glyph first. En-dash entities are
|
||||
// deliberately left alone: the rule counts em-dashes, and the literal `–`
|
||||
// was never counted either.
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
const text = stripHtmlToText(content)
|
||||
.replace(/—|�*8212;|�*2014;/gi, '—');
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(text) !== null) count++;
|
||||
if (count < 5) return [];
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
// Saturation gate: dashes must be dense in the prose, not sprinkled through
|
||||
// a long document. textLength <= count * chars-per-dash means the density is
|
||||
// at or above the threshold.
|
||||
if (text.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
|
||||
},
|
||||
// Marketing buzzwords: SaaS phrase list
|
||||
|
||||
@@ -6,7 +6,13 @@ function getAP(id) {
|
||||
|
||||
function finding(id, filePath, snippet, line = 0) {
|
||||
const ap = getAP(id);
|
||||
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
// Advisory findings are detected but reported separately and never counted as
|
||||
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
|
||||
// can partition without a registry lookup. Only stamped when true to keep the
|
||||
// finding shape stable for the vast majority of rules.
|
||||
if (ap.advisory === true) base.advisory = true;
|
||||
return base;
|
||||
}
|
||||
|
||||
export { getAP, finding };
|
||||
|
||||
@@ -213,9 +213,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -405,6 +410,14 @@ const ANTIPATTERNS = [
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
},
|
||||
{
|
||||
id: 'undersized-ui-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Undersized functional text',
|
||||
description:
|
||||
'Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.',
|
||||
},
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
@@ -556,6 +569,18 @@ function getAntipattern(id) {
|
||||
return ANTIPATTERNS.find(rule => rule.id === id);
|
||||
}
|
||||
|
||||
// Advisory rules are detected and reported, but never treated as failures:
|
||||
// the CLI lists them under a separate "Advisory" section, they do not affect
|
||||
// exit codes or the failure count, and the design hook skips them by default.
|
||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
||||
const ADVISORY_RULE_IDS = new Set(
|
||||
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
|
||||
);
|
||||
|
||||
function isAdvisoryRule(id) {
|
||||
return ADVISORY_RULE_IDS.has(id);
|
||||
}
|
||||
|
||||
function getRulesForCategory(category) {
|
||||
return ANTIPATTERNS.filter(rule => rule.category === category);
|
||||
}
|
||||
@@ -585,8 +610,10 @@ export {
|
||||
ANTIPATTERNS,
|
||||
RULE_SCOPES,
|
||||
RULE_ENGINE_SUPPORT,
|
||||
ADVISORY_RULE_IDS,
|
||||
getAntipattern,
|
||||
getRulesForCategory,
|
||||
getRuleEngineSupport,
|
||||
isAdvisoryRule,
|
||||
filterByScopes,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
BORDER_SAFE_TAGS,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
EM_DASH_FLOOR,
|
||||
GENERIC_FONTS,
|
||||
KNOWN_SERIF_FONTS,
|
||||
OVERUSED_FONTS,
|
||||
@@ -2594,6 +2596,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -2821,6 +2850,27 @@ function textDescendantsFlushSides(el, rect) {
|
||||
return flush;
|
||||
}
|
||||
|
||||
// Screen-reader-only ("visually hidden") text is exempt from the tiny-text
|
||||
// floors: it is never rendered, so its size is irrelevant. Detect the two
|
||||
// standard idioms — a known sr-only class on the element or an ancestor, and
|
||||
// the clip / 1px-box pattern. Works in both jsdom (declared styles) and the
|
||||
// browser (computed styles).
|
||||
const SR_ONLY_SELECTOR = '.sr-only, .visually-hidden, .visuallyhidden, .screen-reader, .screen-reader-only, .screenreader, .a11y-hidden, .hidden-visually, [class*="sr-only" i], [class*="visually-hidden" i], [class*="visuallyhidden" i], [class*="screen-reader" i], [class*="screenreader" i]';
|
||||
function isVisuallyHidden(el, style) {
|
||||
if ((el.matches && el.matches(SR_ONLY_SELECTOR)) || (el.closest && el.closest(SR_ONLY_SELECTOR))) return true;
|
||||
const pos = style.position || '';
|
||||
if (pos === 'absolute' || pos === 'fixed') {
|
||||
const clip = style.clip || '';
|
||||
const clipPath = style.clipPath || style.webkitClipPath || style['clip-path'] || '';
|
||||
if (/rect\(\s*0/.test(clip) || /inset\(\s*(?:50%|99|100%)/.test(clipPath)) return true;
|
||||
const w = parseFloat(style.width);
|
||||
const h = parseFloat(style.height);
|
||||
const overflow = style.overflow || '';
|
||||
if ((w === 1 || h === 1) && (overflow === 'hidden' || overflow === 'clip')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
||||
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
||||
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
||||
@@ -3105,6 +3155,60 @@ function checkQuality(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Undersized functional / UI text ---
|
||||
// Complements `tiny-text` above, which owns long body copy and deliberately
|
||||
// EXEMPTS the UI furniture layer (nav, footer, links, buttons, labels,
|
||||
// uppercase micro-labels). This rule targets exactly that blind spot: the
|
||||
// interactive and short content-bearing text — nav items, buttons, labels,
|
||||
// table cells, meta rows, timecodes — shipped below an 11px floor.
|
||||
//
|
||||
// The live failure it closes: a build shipped its entire furniture layer at
|
||||
// 8px, and the design hook waved it through because 8px had been added to
|
||||
// the DESIGN.md size ramp. Being on the ramp is a token argument, not a
|
||||
// legibility one, so this rule ignores the design system entirely — a value
|
||||
// on the ramp is still flagged.
|
||||
//
|
||||
// Floors: 11px for anything functional. The floor holds inside a footer;
|
||||
// only NON-interactive legal smallprint gets the softer 10px floor. Exempts
|
||||
// sup/sub, visually-hidden (sr-only) text, and code/terminal contexts.
|
||||
// Uppercase letterspaced micro-labels are still functional — not exempt.
|
||||
{
|
||||
const directText = [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
.map(n => n.textContent || '')
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const dtLen = directText.length;
|
||||
const UI_SKIP_TAGS = new Set(['sub', 'sup', 'script', 'style', 'title', 'option']);
|
||||
const notRendered = style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse';
|
||||
// jsdom resolves the parent chain in resolveFontSizePx, so em/rem/%-sized
|
||||
// text that computes at or above the floor never reaches here. The browser
|
||||
// adapter additionally catches values only resolvable with real layout
|
||||
// (e.g. viewport-relative units, cascade winners set in linked sheets).
|
||||
if (fontSize > 0 && fontSize < 11 && dtLen >= 2 && !UI_SKIP_TAGS.has(tag) && !notRendered) {
|
||||
const EXEMPT_CONTEXT = 'pre, code, kbd, samp, var, svg, [aria-hidden="true"], [class*="terminal" i], [class*="console" i], [class*="code" i], [class*="mock" i], [class*="editor" i], [class*="syntax" i], [class*="diff" i]';
|
||||
const isExemptContext = (el.matches && el.matches(EXEMPT_CONTEXT)) || (el.closest && el.closest(EXEMPT_CONTEXT));
|
||||
if (!isExemptContext && !isVisuallyHidden(el, style)) {
|
||||
const INTERACTIVE = 'a[href], button, summary, label, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="treeitem"], [tabindex]';
|
||||
const FURNITURE = 'nav, [role="navigation"], td, th, [role="gridcell"], [role="cell"], caption, figcaption, dt, dd, footer, [class*="meta" i], [class*="label" i], [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="kicker" i], [class*="eyebrow" i], [class*="breadcrumb" i], [class*="timestamp" i], [class*="category" i], [class*="caption" i], [class*="nav" i]';
|
||||
const SMALLPRINT = 'small, footer, [class*="legal" i], [class*="copyright" i], [class*="fineprint" i], [class*="fine-print" i], [class*="smallprint" i], [class*="small-print" i], [class*="disclaimer" i], [class*="disclosure" i], [class*="footnote" i]';
|
||||
const isInteractive = (el.matches && el.matches(INTERACTIVE)) || (el.closest && el.closest(INTERACTIVE));
|
||||
const isFurniture = (el.matches && el.matches(FURNITURE)) || (el.closest && el.closest(FURNITURE));
|
||||
const isSmallprint = (el.matches && el.matches(SMALLPRINT)) || (el.closest && el.closest(SMALLPRINT));
|
||||
const floor = (!isInteractive && isSmallprint) ? 10 : 11;
|
||||
// Fire on functional text only: interactive, structural furniture, or
|
||||
// any short (<=20-char) run — the label / meta / timecode shape. Long
|
||||
// non-furniture body copy stays with `tiny-text`, so the two rules
|
||||
// never double-flag the same element.
|
||||
if (fontSize < floor && (isInteractive || isFurniture || dtLen <= 20)) {
|
||||
const excerpt = directText.slice(0, 40);
|
||||
findings.push({ id: 'undersized-ui-text', snippet: `${fontSize}px functional text "${excerpt}" (below ${floor}px floor)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- All-caps body text ---
|
||||
if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
|
||||
if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
||||
@@ -5121,6 +5225,8 @@ export {
|
||||
checkNumberedSectionLabels,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkNumberedSectionLabelsDOM,
|
||||
checkEmDashOveruse,
|
||||
checkEmDashOveruseDOM,
|
||||
isRepeatedTextContainer,
|
||||
collectRepeatedContainerTextFindings,
|
||||
checkRepeatedContainerTextFromDoc,
|
||||
|
||||
@@ -68,6 +68,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -97,5 +106,7 @@ export {
|
||||
GENERIC_FONTS,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
KNOWN_SERIF_FONTS,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
function arg(name, fallback = null) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
@@ -20,6 +21,183 @@ function arg(name, fallback = null) {
|
||||
return v && !v.startsWith('--') ? v : fallback;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake mode (IMPECCABLE_IMAGE_GEN_FAKE=1)
|
||||
//
|
||||
// Deterministic offline stand-in for the OpenAI call: same prompt -> identical
|
||||
// bytes, no network, no key, cost line reads $0.00. Used by the new-work smoke
|
||||
// suite so the concept/serve-question/image chain can run without spend. The
|
||||
// output renders the prompt over a 2-3 color palette hashed from the prompt,
|
||||
// plus a "SYNTHETIC COMP" corner label. SVG carries the readable text; the
|
||||
// raster (.png/.webp/.jpg) fallback carries palette stripes and stows the
|
||||
// prompt + marker in a PNG tEXt chunk so downstream stays a valid image.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FNV-1a 32-bit: tiny, dependency-free, stable across runs and platforms.
|
||||
function hash32(str) {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function hslToRgb(hDeg, s, l) {
|
||||
const h = ((hDeg % 360) + 360) % 360 / 360;
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const hue = (t) => {
|
||||
let tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||
if (tt < 1 / 2) return q;
|
||||
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||
return p;
|
||||
};
|
||||
return [hue(h + 1 / 3), hue(h), hue(h - 1 / 3)].map((c) => Math.round(c * 255));
|
||||
}
|
||||
|
||||
const toHex = ([r, g, b]) =>
|
||||
'#' + [r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// Two or three deterministic swatches derived from the prompt hash. The band
|
||||
// count itself is prompt-derived, so different prompts differ in palette.
|
||||
function palette(prompt) {
|
||||
const h = hash32(prompt);
|
||||
const base = h % 360;
|
||||
const bands = 2 + (h >>> 9) % 2; // 2 or 3
|
||||
const spread = 40 + (h >>> 3) % 120;
|
||||
const out = [];
|
||||
for (let i = 0; i < bands; i++) {
|
||||
const hue = base + i * spread;
|
||||
const light = 0.32 + ((h >>> (i * 5)) % 40) / 100; // 0.32 - 0.71
|
||||
out.push(hslToRgb(hue, 0.55, light));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function svgFake(prompt, [w, h]) {
|
||||
const colors = palette(prompt).map(toHex);
|
||||
const stops = colors
|
||||
.map((c, i) => `<stop offset="${Math.round((i / (colors.length - 1)) * 100)}%" stop-color="${c}"/>`)
|
||||
.join('');
|
||||
// Greedy word wrap tuned to the canvas width so the prompt stays legible.
|
||||
const perLine = Math.max(12, Math.floor(w / 26));
|
||||
const words = String(prompt).replace(/\s+/g, ' ').trim().split(' ');
|
||||
const lines = [];
|
||||
let cur = '';
|
||||
for (const word of words) {
|
||||
if ((cur + ' ' + word).trim().length > perLine) {
|
||||
if (cur) lines.push(cur);
|
||||
cur = word;
|
||||
} else {
|
||||
cur = (cur + ' ' + word).trim();
|
||||
}
|
||||
if (lines.length >= 10) break;
|
||||
}
|
||||
if (cur && lines.length < 11) lines.push(cur);
|
||||
const escape = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||||
const fontSize = Math.round(w / 24);
|
||||
const startY = h / 2 - ((lines.length - 1) * fontSize * 1.3) / 2;
|
||||
const text = lines
|
||||
.map((line, i) => `<text x="${w / 2}" y="${Math.round(startY + i * fontSize * 1.3)}" font-family="Helvetica, Arial, sans-serif" font-size="${fontSize}" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">${escape(line)}</text>`)
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">${stops}</linearGradient></defs>
|
||||
<rect width="${w}" height="${h}" fill="url(#g)"/>
|
||||
<rect x="0" y="0" width="${w}" height="${h}" fill="#000000" fill-opacity="0.22"/>
|
||||
${text}
|
||||
<rect x="${w - Math.round(w / 4.2)}" y="${h - Math.round(h / 16)}" width="${Math.round(w / 4.2)}" height="${Math.round(h / 16)}" fill="#000000" fill-opacity="0.55"/>
|
||||
<text x="${w - Math.round(w / 8.4)}" y="${h - Math.round(h / 32)}" font-family="Helvetica, Arial, sans-serif" font-size="${Math.round(w / 60)}" letter-spacing="2" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">SYNTHETIC COMP</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
// Minimal valid PNG: palette stripes plus a tEXt chunk carrying the marker and
|
||||
// prompt, so a .png/.webp fake stays a decodable image and still contains the
|
||||
// "SYNTHETIC" bytes downstream tools look for.
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c ^= buf[i];
|
||||
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function pngChunk(type, data) {
|
||||
const typeBuf = Buffer.from(type, 'latin1');
|
||||
const body = Buffer.concat([typeBuf, data]);
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body), 0);
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
|
||||
function pngFake(prompt, [w, h]) {
|
||||
const colors = palette(prompt); // [[r,g,b], ...]
|
||||
const bandH = Math.ceil(h / colors.length);
|
||||
// Raw image: each scanline prefixed with a 0 filter byte, RGB pixels.
|
||||
const stride = w * 3;
|
||||
const raw = Buffer.alloc(h * (stride + 1));
|
||||
for (let y = 0; y < h; y++) {
|
||||
const rowStart = y * (stride + 1);
|
||||
raw[rowStart] = 0;
|
||||
const [r, g, b] = colors[Math.min(colors.length - 1, Math.floor(y / bandH))];
|
||||
for (let x = 0; x < w; x++) {
|
||||
const p = rowStart + 1 + x * 3;
|
||||
raw[p] = r;
|
||||
raw[p + 1] = g;
|
||||
raw[p + 2] = b;
|
||||
}
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0);
|
||||
ihdr.writeUInt32BE(h, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // color type: truecolor RGB
|
||||
const idat = zlib.deflateSync(raw, { level: 9 });
|
||||
const textData = Buffer.concat([
|
||||
Buffer.from('Comment', 'latin1'),
|
||||
Buffer.from([0]),
|
||||
Buffer.from(`SYNTHETIC COMP: ${String(prompt).replace(/\s+/g, ' ').trim()}`, 'latin1'),
|
||||
]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
pngChunk('IHDR', ihdr),
|
||||
pngChunk('tEXt', textData),
|
||||
pngChunk('IDAT', idat),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function parseSize(sizeStr) {
|
||||
const m = String(sizeStr).match(/^(\d+)x(\d+)$/);
|
||||
if (!m) return [1536, 1024];
|
||||
return [Number(m[1]), Number(m[2])];
|
||||
}
|
||||
|
||||
if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
|
||||
const fakePromptFile = arg('prompt-file');
|
||||
const fakePrompt = fakePromptFile ? fs.readFileSync(fakePromptFile, 'utf8') : arg('prompt');
|
||||
const fakeOut = arg('out');
|
||||
if (!fakePrompt || !fakeOut) {
|
||||
console.error('generate-image: --prompt (or --prompt-file) and --out are required.');
|
||||
process.exit(1);
|
||||
}
|
||||
const dims = parseSize(arg('size', '1536x1024'));
|
||||
const bytes = fakeOut.endsWith('.svg')
|
||||
? Buffer.from(svgFake(fakePrompt, dims), 'utf8')
|
||||
: pngFake(fakePrompt, dims);
|
||||
fs.writeFileSync(fakeOut, bytes);
|
||||
console.log(`IMAGE: ${fakeOut} (${dims[0]}x${dims[1]}, fake synthetic comp, $0.00, no API call)`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const key = process.env.OPENAI_API_KEY;
|
||||
if (!key) {
|
||||
console.error('generate-image: OPENAI_API_KEY is not set; use the harness-native image tool instead.');
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* touchFile(cache, sessionId, filePath)
|
||||
* suppressionNotice(filePath)
|
||||
* filterFindings(findings, content, ext, config)
|
||||
* ADVISORY_RULES / isAdvisoryFinding(finding)
|
||||
* IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness)
|
||||
* matchConfiguredExtension(filePath, extensions)
|
||||
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
||||
@@ -126,6 +127,26 @@ export const IMMEDIATE_TIER_RULES = new Set([
|
||||
'design-system-font-size',
|
||||
]);
|
||||
|
||||
// ── Advisory rules ────────────────────────────────────────────────────────
|
||||
// Advisory rules are opt-in noise: the CLI reports them in a separate section
|
||||
// and they never count as failures. The design hook skips them entirely by
|
||||
// default — in both the per-edit PostToolUse pass and the Stop deep pass — so
|
||||
// the agent is never nagged about a taste call a human might make on purpose.
|
||||
// A project opts back in with `.impeccable/config.json`:
|
||||
// { "detector": { "advisoryRules": "include" } }
|
||||
// This set is the hook's own copy of the registry's `advisory: true` rules,
|
||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
||||
// self-contained and testable without loading the detector. Keep it in sync
|
||||
// with the registry (cli/engine/registry/antipatterns.mjs).
|
||||
export const ADVISORY_RULES = new Set([
|
||||
'em-dash-overuse',
|
||||
]);
|
||||
|
||||
export function isAdvisoryFinding(finding) {
|
||||
const id = finding && normalizeIgnoreRule(finding.antipattern);
|
||||
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG = Object.freeze({
|
||||
enabled: true,
|
||||
quiet: false,
|
||||
@@ -136,6 +157,9 @@ export const DEFAULT_CONFIG = Object.freeze({
|
||||
ignoreValues: [],
|
||||
extensions: [],
|
||||
perEditRules: 'immediate',
|
||||
// Advisory rules are skipped unless a project sets detector.advisoryRules to
|
||||
// "include". See ADVISORY_RULES above.
|
||||
advisoryRules: 'exclude',
|
||||
// maxFileBytes: not every generated artifact lives under a path we can
|
||||
// recognize. Committed browser bundles and vendored detector copies sit
|
||||
// next to source and run 200KB+, while genuinely authored stylesheets in
|
||||
@@ -293,6 +317,11 @@ function cloneDefaultConfig() {
|
||||
|
||||
function applyDetectorConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
// `detector.advisoryRules: "include"` opts the hook into advisory rules
|
||||
// (em-dash overuse, etc.). Any other value keeps the default "exclude".
|
||||
if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
|
||||
config.advisoryRules = raw.advisoryRules;
|
||||
}
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
@@ -755,8 +784,12 @@ export function filterFindings(findings, _content, _ext, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config.ignoreValues || []);
|
||||
// Advisory rules are skipped by default so the hook never nags about them;
|
||||
// a project opts in with detector.advisoryRules: "include".
|
||||
const includeAdvisory = (config?.advisoryRules || DEFAULT_CONFIG.advisoryRules) === 'include';
|
||||
return findings.filter((f) => {
|
||||
if (!f || typeof f !== 'object') return false;
|
||||
if (!includeAdvisory && isAdvisoryFinding(f)) return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(f, ignoreValues)) return false;
|
||||
return true;
|
||||
|
||||
@@ -43,7 +43,7 @@ function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
@@ -71,6 +71,11 @@ function cloneRawDetectionConfig() {
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
// Advisory rules are opt-in for the design hook; the CLI carries the setting
|
||||
// so config round-trips (e.g. `impeccable hooks ignore-value`) preserve it.
|
||||
if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
|
||||
config.advisoryRules = raw.advisoryRules;
|
||||
}
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
@@ -151,6 +156,9 @@ function normalizeDetectionConfigForWrite(config) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.advisoryRules === 'include' || config?.advisoryRules === 'exclude') {
|
||||
out.advisoryRules = config.advisoryRules;
|
||||
}
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
* }, ...
|
||||
* ],
|
||||
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
|
||||
* "canon": true, // adds the quiet "Play it straight" standing exit
|
||||
* // (returns {"optionId":"canon"}); direction rounds only
|
||||
* "steer": true // adds a free-text steer field returned with any answer
|
||||
* }
|
||||
*
|
||||
@@ -93,6 +95,24 @@ if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) {
|
||||
}
|
||||
}
|
||||
|
||||
// Both answer channels (blocking stdout and --wait collection) print through
|
||||
// this: the ANSWER line, then a directive to open the chosen card's imagery
|
||||
// when it has any. The card viewing happens at the moment of choice, in the
|
||||
// working turn, because a build that never reopens the chosen world's board
|
||||
// and hero calibrates on nothing.
|
||||
function printAnswer(raw) {
|
||||
console.log(`ANSWER: ${raw}`);
|
||||
try {
|
||||
const a = JSON.parse(raw);
|
||||
if (a.hero || a.board) {
|
||||
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code (download local copies first when your harness only reads files); they set the craft bar the build must reach.");
|
||||
}
|
||||
if (a.optionId === 'canon') {
|
||||
console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.');
|
||||
}
|
||||
} catch { /* raw answer */ }
|
||||
}
|
||||
|
||||
const payloadPath = arg('payload');
|
||||
const timeoutSec = Number(arg('timeout', '900'));
|
||||
const portArg = Number(arg('port', '0'));
|
||||
@@ -109,9 +129,10 @@ if (hasFlag('schema')) {
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', body: 'Fused alternate.', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
],
|
||||
reroll: true,
|
||||
canon: true,
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" is reserved. hero/board accept URLs or local paths.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board accept URLs or local paths. canon adds a quiet standing "Play it straight" action for direction decisions: the user\'s explicit door to the category standard. Include it only for visual-direction rounds; never present canon as your own recommendation.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -144,7 +165,7 @@ if (hasFlag('wait')) {
|
||||
}
|
||||
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
|
||||
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
|
||||
console.log(`ANSWER: ${collected}`);
|
||||
printAnswer(collected);
|
||||
// A re-roll keeps the table open: the server stays alive awaiting --update,
|
||||
// so only the answer file is consumed. Terminal choices clean up fully.
|
||||
let isRerollAnswer = false;
|
||||
@@ -242,12 +263,12 @@ function page() {
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${option.heroSrc || option.boardSrc ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : ''}
|
||||
<div class="media">
|
||||
${option.heroSrc ? `<img src="${esc(option.heroSrc)}" alt="">` : '<div class="hero-blank"></div>'}
|
||||
<div class="chips">${option.heroSrc ? expandChip : ''}${option.boardSrc ? flipChip('Board') : ''}</div>
|
||||
</div>
|
||||
${option.heroSrc || option.boardSrc ? `<div class="media">
|
||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||
<div class="chips">${expandChip}${option.boardSrc && option.heroSrc ? flipChip('Board') : ''}</div>
|
||||
</div>` : ''}
|
||||
<div class="body">
|
||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||
<h2>${esc(option.label)}</h2>
|
||||
@@ -255,7 +276,7 @@ function page() {
|
||||
<button class="choose" data-id="${esc(option.id)}">Build this</button>
|
||||
</div>
|
||||
</div>
|
||||
${option.boardSrc ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
${option.boardSrc && option.heroSrc ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
<div class="media back-media">
|
||||
<img src="${esc(option.boardSrc)}" alt="">
|
||||
<div class="chips">${expandChip}${flipChip('Hero')}</div>
|
||||
@@ -328,6 +349,10 @@ function page() {
|
||||
.card:hover .face.lead { border-color: var(--ks-kinpaku); }
|
||||
@media (prefers-reduced-motion: reduce) { .card-inner { transition: none; } }
|
||||
.kicker { position: absolute; z-index: 2; top: 12px; left: 12px; padding: 4px 10px; background: var(--ks-kinpaku); color: var(--ks-dark-ink); font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .24em; text-transform: uppercase; border-radius: 4px; }
|
||||
/* Text-only card: a grounded direction with no rendered card drops the media
|
||||
region entirely instead of reserving a blank 16:9 void. */
|
||||
.face.text-only .kicker { position: static; align-self: flex-start; margin: 14px 0 0 14px; }
|
||||
.face.text-only .body { padding-top: 12px; }
|
||||
.media { position: relative; width: 100%; aspect-ratio: 16/9; flex: none; }
|
||||
.media img { width: 100%; height: 100%; object-fit: cover; display: block; background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
|
||||
.face.back { background: var(--ks-lacquer-deep); }
|
||||
@@ -350,6 +375,11 @@ function page() {
|
||||
#reroll { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
#reroll svg { width: 15px; height: 15px; }
|
||||
/* The quiet exit: always available, never argued with, visually subordinate
|
||||
to the dealt cards and the re-roll so it reads as the user's own door,
|
||||
not a recommendation. */
|
||||
#canon { align-self: center; padding: 0 4px; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: inherit; opacity: .45; background: transparent; border: none; border-bottom: 1px dotted currentColor; cursor: pointer; transition: opacity .2s ease; }
|
||||
#canon:hover { opacity: .85; }
|
||||
.card.skeleton .media { background: var(--ks-graphite); }
|
||||
.shimmer { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
|
||||
.card.skeleton .line { height: 11px; border-radius: 4px; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
|
||||
@@ -384,6 +414,7 @@ function page() {
|
||||
<footer>
|
||||
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
|
||||
${payload.reroll ? '<button id="reroll"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg><span>Re-roll</span></button>' : ''}
|
||||
${payload.canon ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
|
||||
</footer>
|
||||
<script>
|
||||
const steer = () => document.getElementById('steer')?.value || '';
|
||||
@@ -454,6 +485,7 @@ function page() {
|
||||
const closeLightbox = () => { lightbox.classList.remove('open'); setTimeout(() => { lightbox.hidden = true; }, 250); };
|
||||
lightbox.addEventListener('click', closeLightbox);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
||||
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
|
||||
document.getElementById('reroll')?.addEventListener('click', async () => {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
|
||||
const grid = document.querySelector('.grid');
|
||||
@@ -518,7 +550,11 @@ const server = http.createServer((req, res) => {
|
||||
if (imageMatch) {
|
||||
const abs = localImages[Number(imageMatch[1])];
|
||||
if (!abs) { res.writeHead(404); res.end(); return; }
|
||||
const type = abs.endsWith('.webp') ? 'image/webp' : abs.endsWith('.png') ? 'image/png' : 'image/jpeg';
|
||||
const type = abs.endsWith('.webp') ? 'image/webp'
|
||||
: abs.endsWith('.png') ? 'image/png'
|
||||
: abs.endsWith('.svg') ? 'image/svg+xml'
|
||||
: abs.endsWith('.gif') ? 'image/gif'
|
||||
: 'image/jpeg';
|
||||
res.writeHead(200, { 'content-type': type });
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
return;
|
||||
@@ -531,13 +567,18 @@ const server = http.createServer((req, res) => {
|
||||
res.end('{"ok":true}');
|
||||
let parsed = {};
|
||||
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
|
||||
const answer = JSON.stringify({ optionId: parsed.optionId ?? null, steer: parsed.steer ?? '' });
|
||||
const chosen = options.find((o) => o.id === parsed.optionId);
|
||||
const answer = JSON.stringify({
|
||||
optionId: parsed.optionId ?? null,
|
||||
steer: parsed.steer ?? '',
|
||||
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||
});
|
||||
const isReroll = parsed.optionId === 'reroll';
|
||||
if (detachedKey) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
|
||||
} else {
|
||||
console.log(`ANSWER: ${answer}`);
|
||||
printAnswer(answer);
|
||||
}
|
||||
// A re-roll in detached mode keeps the table open: the client shows a
|
||||
// loading hand and reloads when --update delivers the next round.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 4.0.0
|
||||
version: 4.0.1
|
||||
user-invocable: true
|
||||
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
|
||||
license: Apache 2.0
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Codex: Surface Probes & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) only when the harness has native image generation and a substantial, high-fidelity surface would benefit from seeing the shortlisted concept before code. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate the smallest useful probe set
|
||||
|
||||
Generate one to three high-fidelity north-star comps using the native image-generation capability. Base them on the real content and the surface concepts already developed with the user.
|
||||
|
||||
- When the user shortlisted multiple concepts, show one clear expression of each.
|
||||
- When one concept is already selected, vary only the structural uncertainty that the image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||
- Show enough beyond the opening moment to prove the concept can govern the whole requested surface.
|
||||
- Do not generate a palette artifact, ask new atmosphere questions, introduce a different type voice, or invent a new motif. If the committed world cannot support the concept, return to the concept shortlist rather than changing the world.
|
||||
|
||||
Treat each comp as a direction test, not a screenshot specification. Core UI text, responsive behavior, accessibility, semantics, and interaction states remain implementation responsibilities.
|
||||
|
||||
## One approval point
|
||||
|
||||
Show the probes together and ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
After approval, summarize the composition and the parts of the comp that must not be literalized. Return to new-work.md, record the direction contract from the approved surface concept, then build.
|
||||
|
||||
## Inventory implementation fidelity
|
||||
|
||||
Before building, inventory the approved comp's major visible ingredients 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.
|
||||
|
||||
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies. If the concept depends on a photograph, architectural scene, product object, portrait, or other raster-native material, do not silently replace it with generic CSS scenery.
|
||||
|
||||
Treat the comp as a north star, not something to trace. 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
|
||||
|
||||
When clean raster ingredients are required and a scoped subagent is available and authorized, use `impeccable_asset_producer`. Give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets with the native image-generation capability in the current thread.
|
||||
|
||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||
@@ -44,9 +44,11 @@ The script assigns which structure gets built: your top-ranked structure is what
|
||||
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, the notation, publications, identity programs, data graphics, and interfaces it reads daily, not only its physical objects; a nameable abstract system (a school of poster, a documentation standard, a data-graphic tradition) 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; the audience's world is larger than that, so 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 .github/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Offer re-roll with an optional one-line steer instead of a ranked menu; a lineup invites the safest card. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Pick the channel by capability, not by habit: can you put a page in front of the user, through an in-app browser or by opening a browser window on their machine outside your harness? If yes, present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll plus steer enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll, then run `node .github/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, preferring the in-app browser when the harness has one, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. If no, because the session is headless, CI, an eval worker, or a remote shell with no display, skip the page and put the same decision through the structured question tool; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the one or two fused challengers that survived the weighing as named alternates with a one-line case each, plus re-roll with an optional one-line steer. What you never present is a ranked menu of your own grounded candidates; a lineup of those invites the safest card, while dealt challengers carry no such rut. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list also carries the standing exit as its last option.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. When the harness can view images, open the QUALITY BAR board and hero the seed prints for the world you build (when it only reads local images, download the card to a temp file first and view that): they set the craft level the build must reach, the finish, commitment, and art direction of a rendered reference, and never dictate the composition; your surface serves this product.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Run `node .github/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images for exactly this reason (download to a temp file when the harness only reads local images). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
|
||||
|
||||
Every direction the roll can land on must already be viable: every relationship and claim it visualizes true, a real palette and component family, a distinctive composition with one product-specific experience, workable at full-surface scale within the available assets, tools, and performance budget. A candidate that fails on truth is replaced before the roll, never rescued by it. Truth binds claims, not demonstrations: in greenfield work, author whatever illustrative material the concept needs at full fidelity, label it synthetic wherever a visitor could mistake it for the real thing, and hand the user the list of what to replace with real material. What stays uninventable are commercial and factual claims: prices, customers, benchmarks, endpoints, capabilities the product does not have. Refusing a bold direction because its demonstration data does not exist yet is the timidity reflex wearing honesty's clothes.
|
||||
|
||||
@@ -62,10 +64,12 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su
|
||||
|
||||
## 5. Record the decision
|
||||
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. 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. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, the staging chosen for it, and the seed key the script printed; when the seed dealt stagings, choosing the world does not silently discharge the staging decision, so name the one you commit to. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
|
||||
When a new or replacement world is chosen, DESIGN.md is part of recording the decision, not an aftercare step: write it at the appropriate project or app boundary using [document.md](document.md) before the first build edit lands, in the same working stretch as the direction contract. Record only durable system rules; exact tokens may remain provisional until the first build establishes them, and you update the file when the build settles them. A new world shipped with no DESIGN.md is an incomplete run, exactly as a missing PRODUCT.md is; the finishing review checks the file exists and matches the built world. An ordinary extension does not rewrite DESIGN.md.
|
||||
|
||||
Two observed ways a DESIGN.md rule goes wrong: a prohibition that bans a device the world itself uses natively (arcade lettering lives on its offset shadow), and a token added so a hook finding stops firing. Check every prohibition against the world's own materials; a value earns its place by the world and by legibility, never by silencing a check.
|
||||
|
||||
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
|
||||
|
||||
`node .github/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
|
||||
@@ -74,7 +78,7 @@ If the work establishes durable strategy for a route or artifact, read its exist
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
Visualize before you build whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports: render the chosen direction as a design-system board and a first-surface mock, correct material drift between mock and intent, then build. Seeing the direction first measurably strengthens the result. [codex.md](codex.md) carries the deep native-generation flow; the mock is a selection aid, not authority.
|
||||
Whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options rendered and put before the user for approval. This step is proven to produce the most compositional and ambitious work.
|
||||
|
||||
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
|
||||
|
||||
@@ -84,8 +88,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie
|
||||
|
||||
- **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. 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; never invent prices, customers, benchmarks, or capabilities.
|
||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, titles, 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, and no section is omittable. When a commercial claim stayed unanswered, ship a clearly marked placeholder value and hand the user the replacement list; a section thinned or dropped for missing truth is the asset gap wearing honesty's clothes. Decoration compensating for missing content, gradients, glass, borders, icon tiles where an authored asset belongs, is the same gap wearing chrome's.
|
||||
- **Generate the imagery the build needs.** When any image generation is available, a harness-native tool or the API fallback context.mjs reports, generating the design's imagery is part of building: heroes, covers, demonstration thumbnails, textures, marks. Gray boxes and stock-styled placeholders are not a fallback while generation is one command away; state the cost once before the first render and batch what the surface needs.
|
||||
- **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 that before a line of code exists. 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.
|
||||
@@ -97,4 +100,4 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
||||
|
||||
Inspect desktop and mobile, critique the render against the user's request, the direction contract, and DESIGN.md, fix material gaps, and re-inspect. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary. After a first implementation of a new world, update DESIGN.md with the exact tokens and behaviors that survived the build.
|
||||
|
||||
When the harness can run a separate agent, this review belongs there, not in the build thread: give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, and existing hook findings. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector.
|
||||
Spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex), and give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, existing hook findings, and the QUALITY BAR card and approved comp paths. This review never runs inside the build thread; a harness with no subagents runs it as its own fresh pass, after stepping fully out of the build context. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Its second check is the ceiling: against the chosen world's QUALITY BAR card and the approved comp, name the native devices the build left unused, frame, depth, lettering, ornament density; floor rigor is very good at disguising an unreached card. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Visualize: Direction Comps & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate three compositional options
|
||||
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building.
|
||||
|
||||
- When the user shortlisted multiple concepts, spread the three across them.
|
||||
- When one direction is committed, vary the structural uncertainty an image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||
- Show enough beyond the opening moment to prove the concept can govern the whole requested surface.
|
||||
- Do not generate a palette artifact, ask new atmosphere questions, introduce a different type voice, or invent a new motif. If the committed world cannot support the concept, return to the concept shortlist rather than changing the world.
|
||||
|
||||
Treat each comp as a direction test, not a screenshot specification. Core UI text, responsive behavior, accessibility, semantics, and interaction states remain implementation responsibilities.
|
||||
|
||||
## One approval point
|
||||
|
||||
Show the three together: in the harness when it can display images, otherwise on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero). Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
After approval, summarize the composition and the parts of the comp that must not be literalized. Return to new-work.md, record the direction contract from the approved surface concept, then build.
|
||||
|
||||
## Inventory implementation fidelity
|
||||
|
||||
Before building, inventory the approved comp's major visible ingredients 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.
|
||||
|
||||
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies. If the concept depends on a photograph, architectural scene, product object, portrait, or other raster-native material, do not silently replace it with generic CSS scenery.
|
||||
|
||||
Treat the comp as a north star, not something to trace. 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
|
||||
|
||||
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread with whatever generation exists, the native tool or generate-image.mjs.
|
||||
|
||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||
@@ -79,6 +79,13 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_DIR = process.env.IMPECCABLE_CATALOG_DIR || here;
|
||||
const API_BASE = (process.env.IMPECCABLE_API_URL || 'https://impeccable.style/api').replace(/\/$/, '');
|
||||
const API_TIMEOUT_MS = Number(process.env.IMPECCABLE_API_TIMEOUT || 4000);
|
||||
// All API calls in one seed run share a single deadline so an unreachable
|
||||
// network degrades after one timeout total, never one timeout per call.
|
||||
let apiDeadline = null;
|
||||
function apiBudgetMs() {
|
||||
if (apiDeadline === null) apiDeadline = Date.now() + API_TIMEOUT_MS;
|
||||
return Math.max(0, apiDeadline - Date.now());
|
||||
}
|
||||
|
||||
const localStates = new Map();
|
||||
function loadLocal(catalogDir = CATALOG_DIR) {
|
||||
@@ -120,9 +127,15 @@ async function fetchRoll({ scope, key, mode, reroll }) {
|
||||
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
|
||||
if (mode) params.set('mode', mode);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal });
|
||||
// Race the budget explicitly: abort signals do not reliably cancel the
|
||||
// TCP connect phase, so a blackholed route would otherwise stall ~10s.
|
||||
const response = await Promise.race([
|
||||
fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal }),
|
||||
new Promise(resolveTimeout => setTimeout(() => resolveTimeout(null), apiBudgetMs())),
|
||||
]);
|
||||
if (!response) return null;
|
||||
if (!response.ok) return null;
|
||||
const roll = await response.json();
|
||||
if (!Array.isArray(roll.challengers) || roll.challengers.length === 0) return null;
|
||||
@@ -143,7 +156,7 @@ function telemetryDisabled() {
|
||||
export async function pingChosen({ chosenId, key, scope, mode }) {
|
||||
if (telemetryDisabled() || !chosenId) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
await fetch(`${API_BASE}/chosen`, {
|
||||
method: 'POST',
|
||||
@@ -445,7 +458,10 @@ ASSIGNED INDEX: ${buildIndex}
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
No challengers this run: the roll service was unreachable and no local
|
||||
catalog exists. Proceed with the grounded candidates alone; the assignment
|
||||
catalog exists. A sandboxed shell without network egress is the most common
|
||||
cause: if this harness can rerun the command with network access granted,
|
||||
do that once before proceeding. Otherwise proceed with the grounded
|
||||
candidates alone; the assignment
|
||||
above still applies at full strength. Tell the user plainly that this roll
|
||||
ran degraded, with no challengers and no quality-bar boards; do not present
|
||||
the outcome as a full roll. A degraded roll changes the cards, not the
|
||||
@@ -543,4 +559,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
// A raced-out fetch may still hold a socket; exit explicitly so the CLI
|
||||
// never lingers on a dead network path after output is written.
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
|
||||
@@ -1223,6 +1223,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -1541,6 +1545,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadDesignSystemForCwd } from '../design-system.mjs';
|
||||
import { loadDesignSystemForTarget } from '../design-system.mjs';
|
||||
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs';
|
||||
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
|
||||
import { detectHtml } from '../engines/static-html/detect-html.mjs';
|
||||
@@ -27,9 +28,37 @@ function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
// Local filesystem path behind a file:// URL, or null when it can't be mapped.
|
||||
function fileUrlToLocalPath(url) {
|
||||
try {
|
||||
return fileURLToPath(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Advisory findings are detected but never treated as failures: they list in a
|
||||
// separate, visually dimmed section, are excluded from the failure count that
|
||||
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
|
||||
// filter. Every advisory finding carries the flag (stamped by the registry via
|
||||
// findings.mjs).
|
||||
function isAdvisory(finding) {
|
||||
return finding && finding.advisory === true;
|
||||
}
|
||||
|
||||
function partitionAdvisory(findings) {
|
||||
const primary = [];
|
||||
const advisory = [];
|
||||
for (const f of findings) (isAdvisory(f) ? advisory : primary).push(f);
|
||||
return { primary, advisory };
|
||||
}
|
||||
|
||||
// ANSI dim, when stderr is a TTY. Advisory output is chrome, so keep it quiet.
|
||||
function dim(text) {
|
||||
return process.stderr.isTTY ? `\x1b[2m${text}\x1b[0m` : text;
|
||||
}
|
||||
|
||||
function formatFindingsBody(findings) {
|
||||
const grouped = {};
|
||||
for (const f of findings) {
|
||||
if (!grouped[f.file]) grouped[f.file] = [];
|
||||
@@ -44,7 +73,28 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatAdvisorySection(advisory) {
|
||||
if (!advisory || advisory.length === 0) return '';
|
||||
const lines = [`\n${dim('── Advisory (not counted as failures) ──')}`];
|
||||
for (const line of formatFindingsBody(advisory)) lines.push(dim(line));
|
||||
lines.push(dim(`\n${advisory.length} advisory note${advisory.length === 1 ? '' : 's'}. Suppress with --no-advisory.`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Text/JSON formatter. `findings` is the full set; advisory items are separated
|
||||
// out into their own section and excluded from the failure summary count. JSON
|
||||
// output keeps every finding (each advisory one flagged) in a single array.
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
const { primary, advisory } = partitionAdvisory(findings);
|
||||
const out = [...formatFindingsBody(primary)];
|
||||
out.push(`\n${formatFindingSummary(primary.length)}`);
|
||||
const advisorySection = formatAdvisorySection(advisory);
|
||||
if (advisorySection) out.push(advisorySection);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -52,7 +102,11 @@ function formatFindings(findings, jsonMode) {
|
||||
// Stdin handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleStdin(options = {}) {
|
||||
// `optionsFor` maps a local path to scan options carrying that path's own
|
||||
// project design system (or base options when null). Falls back to a plain
|
||||
// object so direct/legacy callers still work.
|
||||
async function handleStdin(optionsFor = () => ({})) {
|
||||
const resolve = typeof optionsFor === 'function' ? optionsFor : () => optionsFor;
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const input = Buffer.concat(chunks).toString('utf-8');
|
||||
@@ -60,11 +114,12 @@ async function handleStdin(options = {}) {
|
||||
const parsed = JSON.parse(input);
|
||||
const fp = parsed?.tool_input?.file_path;
|
||||
if (fp && fs.existsSync(fp)) {
|
||||
const options = resolve(fp);
|
||||
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
|
||||
? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options);
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return detectText(input, '<stdin>', options);
|
||||
return detectText(input, '<stdin>', resolve(null));
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +155,14 @@ Options:
|
||||
ignore comments, or DESIGN.md
|
||||
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
|
||||
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
|
||||
--no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)
|
||||
--help Show this help message
|
||||
|
||||
Advisory findings:
|
||||
Some rules are advisory: detected and listed in a separate section, but never
|
||||
counted as failures and never changing the exit code. They stay out of the
|
||||
failure count so they never block automation. --no-advisory hides them.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -139,6 +200,7 @@ async function detectCli() {
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
const noAdvisory = args.includes('--no-advisory');
|
||||
// --fast (regex-only) is deprecated: since the jsdom removal, the static
|
||||
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
|
||||
// only loses coverage for no real speed win. Accept the flag for back-compat
|
||||
@@ -199,14 +261,23 @@ async function detectCli() {
|
||||
process.exit(1);
|
||||
}
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
|
||||
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
|
||||
// apply by default. `--no-config` (raw scan) and the dedicated
|
||||
// `--no-inline-ignores` both turn them off.
|
||||
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
|
||||
const scanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
if (viewport) scanOptions.viewport = viewport;
|
||||
const baseScanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (viewport) baseScanOptions.viewport = viewport;
|
||||
// DESIGN.md must resolve from EACH scan target's own project root, not from
|
||||
// process.cwd(): scanning project B's files from inside project A applied A's
|
||||
// design rules (cross-project contamination). Resolve per target, memoized by
|
||||
// resolved project root so a multi-file scan pays the read once per project.
|
||||
// A target with no project marker above it gets no design system (never cwd's).
|
||||
const designSystemCache = new Map();
|
||||
const scanOptionsFor = (localPath) => {
|
||||
if (!designSystemEnabled || !localPath) return baseScanOptions;
|
||||
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
||||
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
|
||||
};
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -214,7 +285,7 @@ async function detectCli() {
|
||||
let allFindings = [];
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptions);
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
} else {
|
||||
const paths = targets.length > 0 ? targets : [process.cwd()];
|
||||
// file:// URLs get the same Puppeteer-rendered pass as http(s) — the
|
||||
@@ -228,10 +299,17 @@ async function detectCli() {
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (urlRe.test(target)) {
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
// process.cwd()'s.
|
||||
const urlOptions = /^file:/i.test(target)
|
||||
? scanOptionsFor(fileUrlToLocalPath(target))
|
||||
: baseScanOptions;
|
||||
try {
|
||||
const scanner = browserDetector
|
||||
? (url) => browserDetector.detectUrl(url, scanOptions)
|
||||
: (url) => detectUrl(url, scanOptions);
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
@@ -297,11 +375,14 @@ async function detectCli() {
|
||||
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
let fileFindings;
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
fileFindings = await detectHtml(file, scanOptions);
|
||||
fileFindings = await detectHtml(file, fileOptions);
|
||||
} else {
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions);
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, fileOptions);
|
||||
}
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
@@ -316,10 +397,11 @@ async function detectCli() {
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
allFindings.push(...await detectHtml(resolved, scanOptions));
|
||||
allFindings.push(...await detectHtml(resolved, fileOptions));
|
||||
} else {
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions));
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,12 +412,24 @@ async function detectCli() {
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
// --no-advisory drops advisory findings before any output or exit-code math.
|
||||
if (noAdvisory) allFindings = allFindings.filter((f) => !isAdvisory(f));
|
||||
|
||||
// The exit code and failure count reflect non-advisory findings only. An
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else if (quietMode) {
|
||||
process.stderr.write(formatFindingSummary(primary.length) + '\n');
|
||||
if (advisory.length > 0) {
|
||||
process.stderr.write(dim(`${advisory.length} advisory note${advisory.length === 1 ? '' : 's'} (not counted).`) + '\n');
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { finding } from './findings.mjs';
|
||||
@@ -7,6 +8,11 @@ import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs';
|
||||
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// Files/dirs whose presence marks a directory as a project root. Mirrors the
|
||||
// walk-up semantics of skill/scripts/context.mjs (`resolveProject`), which the
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
@@ -469,6 +475,62 @@ function loadDesignSystemForCwd(cwd = process.cwd()) {
|
||||
});
|
||||
}
|
||||
|
||||
// Directory to begin the project-root walk from, given a scan target that may
|
||||
// be a file or a directory (and may not exist yet).
|
||||
function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
return fs.statSync(abs).isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
// Nonexistent path: treat an extension-bearing leaf as a file.
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
// Returns { dir, hasDesign } for the stopping directory, or null when the walk
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the design system that governs a specific scan target, by walking up
|
||||
// from the target's own location — never process.cwd(). Scanning project B's
|
||||
// files from inside project A applies B's DESIGN.md (or none), not A's.
|
||||
//
|
||||
// Pass a `cache` Map to memoize by resolved design root across a multi-file
|
||||
// scan; a target with no design root above it resolves to null.
|
||||
export function loadDesignSystemForTarget(targetPath, { cache, cwd = process.cwd() } = {}) {
|
||||
const startDir = designSystemStartDir(targetPath, cwd);
|
||||
const found = findDesignRoot(startDir);
|
||||
const key = found ? `root:${found.dir}` : '\0none';
|
||||
if (cache && cache.has(key)) return cache.get(key);
|
||||
const loaded = found?.hasDesign ? loadDesignSystemForCwd(found.dir) : null;
|
||||
if (cache) cache.set(key, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function isAllowedFont(font, designSystem) {
|
||||
if (!font || GENERIC_FONTS.has(font)) return true;
|
||||
if (!designSystem?.hasFonts) return true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS, EM_DASH_FLOOR, EM_DASH_CHARS_PER_DASH } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
@@ -16,6 +16,7 @@ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
|
||||
const hasBorderRadius = (line) => /border-radius/i.test(line);
|
||||
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
|
||||
|
||||
|
||||
/** Strip HTML to plain text — drops script/style/comments/tags so
|
||||
* content-text analyzers don't false-positive on code or CSS. */
|
||||
function stripHtmlToText(html) {
|
||||
@@ -306,15 +307,34 @@ const REGEX_ANALYZERS = [
|
||||
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
||||
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
|
||||
},
|
||||
// Em-dash overuse: 5+ em-dashes or "--" in body text content
|
||||
// (occasional em-dash use in prose is fine; the pattern fires only
|
||||
// when count crosses into AI-cadence territory).
|
||||
// Em-dash overuse (ADVISORY): the AI cadence tell is em-dash *saturation*,
|
||||
// not the occasional dash. Humans use em-dashes legitimately, so this rule is
|
||||
// advisory (surfaced separately, never a failure, hook-skipped by default) and
|
||||
// its threshold is deliberately conservative. Two gates must both hold:
|
||||
// 1. Absolute floor of EM_DASH_FLOOR (8) dashes — a page with a handful
|
||||
// never fires, no matter how short.
|
||||
// 2. Density: at least one dash per EM_DASH_CHARS_PER_DASH (500) characters
|
||||
// of body text, so a long article that uses eight across several thousand
|
||||
// words is left alone while a short, dash-per-clause landing page is not.
|
||||
// Raised from the old flat 5-dash floor, which fired on ordinary long prose.
|
||||
//
|
||||
// stripHtmlToText drops tags but leaves character-entity escapes intact, so
|
||||
// a model that writes `—`, `—`, or `—` renders an em-dash
|
||||
// the counter never saw. Decode the em-dash entities (named, zero-padded
|
||||
// decimal, upper/lower hex) to the literal glyph first. En-dash entities are
|
||||
// deliberately left alone: the rule counts em-dashes, and the literal `–`
|
||||
// was never counted either.
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
const text = stripHtmlToText(content)
|
||||
.replace(/—|�*8212;|�*2014;/gi, '—');
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(text) !== null) count++;
|
||||
if (count < 5) return [];
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
// Saturation gate: dashes must be dense in the prose, not sprinkled through
|
||||
// a long document. textLength <= count * chars-per-dash means the density is
|
||||
// at or above the threshold.
|
||||
if (text.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
|
||||
},
|
||||
// Marketing buzzwords: SaaS phrase list
|
||||
|
||||
@@ -6,7 +6,13 @@ function getAP(id) {
|
||||
|
||||
function finding(id, filePath, snippet, line = 0) {
|
||||
const ap = getAP(id);
|
||||
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
// Advisory findings are detected but reported separately and never counted as
|
||||
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
|
||||
// can partition without a registry lookup. Only stamped when true to keep the
|
||||
// finding shape stable for the vast majority of rules.
|
||||
if (ap.advisory === true) base.advisory = true;
|
||||
return base;
|
||||
}
|
||||
|
||||
export { getAP, finding };
|
||||
|
||||
@@ -213,9 +213,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -405,6 +410,14 @@ const ANTIPATTERNS = [
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
},
|
||||
{
|
||||
id: 'undersized-ui-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Undersized functional text',
|
||||
description:
|
||||
'Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.',
|
||||
},
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
@@ -556,6 +569,18 @@ function getAntipattern(id) {
|
||||
return ANTIPATTERNS.find(rule => rule.id === id);
|
||||
}
|
||||
|
||||
// Advisory rules are detected and reported, but never treated as failures:
|
||||
// the CLI lists them under a separate "Advisory" section, they do not affect
|
||||
// exit codes or the failure count, and the design hook skips them by default.
|
||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
||||
const ADVISORY_RULE_IDS = new Set(
|
||||
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
|
||||
);
|
||||
|
||||
function isAdvisoryRule(id) {
|
||||
return ADVISORY_RULE_IDS.has(id);
|
||||
}
|
||||
|
||||
function getRulesForCategory(category) {
|
||||
return ANTIPATTERNS.filter(rule => rule.category === category);
|
||||
}
|
||||
@@ -585,8 +610,10 @@ export {
|
||||
ANTIPATTERNS,
|
||||
RULE_SCOPES,
|
||||
RULE_ENGINE_SUPPORT,
|
||||
ADVISORY_RULE_IDS,
|
||||
getAntipattern,
|
||||
getRulesForCategory,
|
||||
getRuleEngineSupport,
|
||||
isAdvisoryRule,
|
||||
filterByScopes,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
BORDER_SAFE_TAGS,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
EM_DASH_FLOOR,
|
||||
GENERIC_FONTS,
|
||||
KNOWN_SERIF_FONTS,
|
||||
OVERUSED_FONTS,
|
||||
@@ -2594,6 +2596,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -2821,6 +2850,27 @@ function textDescendantsFlushSides(el, rect) {
|
||||
return flush;
|
||||
}
|
||||
|
||||
// Screen-reader-only ("visually hidden") text is exempt from the tiny-text
|
||||
// floors: it is never rendered, so its size is irrelevant. Detect the two
|
||||
// standard idioms — a known sr-only class on the element or an ancestor, and
|
||||
// the clip / 1px-box pattern. Works in both jsdom (declared styles) and the
|
||||
// browser (computed styles).
|
||||
const SR_ONLY_SELECTOR = '.sr-only, .visually-hidden, .visuallyhidden, .screen-reader, .screen-reader-only, .screenreader, .a11y-hidden, .hidden-visually, [class*="sr-only" i], [class*="visually-hidden" i], [class*="visuallyhidden" i], [class*="screen-reader" i], [class*="screenreader" i]';
|
||||
function isVisuallyHidden(el, style) {
|
||||
if ((el.matches && el.matches(SR_ONLY_SELECTOR)) || (el.closest && el.closest(SR_ONLY_SELECTOR))) return true;
|
||||
const pos = style.position || '';
|
||||
if (pos === 'absolute' || pos === 'fixed') {
|
||||
const clip = style.clip || '';
|
||||
const clipPath = style.clipPath || style.webkitClipPath || style['clip-path'] || '';
|
||||
if (/rect\(\s*0/.test(clip) || /inset\(\s*(?:50%|99|100%)/.test(clipPath)) return true;
|
||||
const w = parseFloat(style.width);
|
||||
const h = parseFloat(style.height);
|
||||
const overflow = style.overflow || '';
|
||||
if ((w === 1 || h === 1) && (overflow === 'hidden' || overflow === 'clip')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
||||
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
||||
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
||||
@@ -3105,6 +3155,60 @@ function checkQuality(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Undersized functional / UI text ---
|
||||
// Complements `tiny-text` above, which owns long body copy and deliberately
|
||||
// EXEMPTS the UI furniture layer (nav, footer, links, buttons, labels,
|
||||
// uppercase micro-labels). This rule targets exactly that blind spot: the
|
||||
// interactive and short content-bearing text — nav items, buttons, labels,
|
||||
// table cells, meta rows, timecodes — shipped below an 11px floor.
|
||||
//
|
||||
// The live failure it closes: a build shipped its entire furniture layer at
|
||||
// 8px, and the design hook waved it through because 8px had been added to
|
||||
// the DESIGN.md size ramp. Being on the ramp is a token argument, not a
|
||||
// legibility one, so this rule ignores the design system entirely — a value
|
||||
// on the ramp is still flagged.
|
||||
//
|
||||
// Floors: 11px for anything functional. The floor holds inside a footer;
|
||||
// only NON-interactive legal smallprint gets the softer 10px floor. Exempts
|
||||
// sup/sub, visually-hidden (sr-only) text, and code/terminal contexts.
|
||||
// Uppercase letterspaced micro-labels are still functional — not exempt.
|
||||
{
|
||||
const directText = [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
.map(n => n.textContent || '')
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const dtLen = directText.length;
|
||||
const UI_SKIP_TAGS = new Set(['sub', 'sup', 'script', 'style', 'title', 'option']);
|
||||
const notRendered = style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse';
|
||||
// jsdom resolves the parent chain in resolveFontSizePx, so em/rem/%-sized
|
||||
// text that computes at or above the floor never reaches here. The browser
|
||||
// adapter additionally catches values only resolvable with real layout
|
||||
// (e.g. viewport-relative units, cascade winners set in linked sheets).
|
||||
if (fontSize > 0 && fontSize < 11 && dtLen >= 2 && !UI_SKIP_TAGS.has(tag) && !notRendered) {
|
||||
const EXEMPT_CONTEXT = 'pre, code, kbd, samp, var, svg, [aria-hidden="true"], [class*="terminal" i], [class*="console" i], [class*="code" i], [class*="mock" i], [class*="editor" i], [class*="syntax" i], [class*="diff" i]';
|
||||
const isExemptContext = (el.matches && el.matches(EXEMPT_CONTEXT)) || (el.closest && el.closest(EXEMPT_CONTEXT));
|
||||
if (!isExemptContext && !isVisuallyHidden(el, style)) {
|
||||
const INTERACTIVE = 'a[href], button, summary, label, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="treeitem"], [tabindex]';
|
||||
const FURNITURE = 'nav, [role="navigation"], td, th, [role="gridcell"], [role="cell"], caption, figcaption, dt, dd, footer, [class*="meta" i], [class*="label" i], [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="kicker" i], [class*="eyebrow" i], [class*="breadcrumb" i], [class*="timestamp" i], [class*="category" i], [class*="caption" i], [class*="nav" i]';
|
||||
const SMALLPRINT = 'small, footer, [class*="legal" i], [class*="copyright" i], [class*="fineprint" i], [class*="fine-print" i], [class*="smallprint" i], [class*="small-print" i], [class*="disclaimer" i], [class*="disclosure" i], [class*="footnote" i]';
|
||||
const isInteractive = (el.matches && el.matches(INTERACTIVE)) || (el.closest && el.closest(INTERACTIVE));
|
||||
const isFurniture = (el.matches && el.matches(FURNITURE)) || (el.closest && el.closest(FURNITURE));
|
||||
const isSmallprint = (el.matches && el.matches(SMALLPRINT)) || (el.closest && el.closest(SMALLPRINT));
|
||||
const floor = (!isInteractive && isSmallprint) ? 10 : 11;
|
||||
// Fire on functional text only: interactive, structural furniture, or
|
||||
// any short (<=20-char) run — the label / meta / timecode shape. Long
|
||||
// non-furniture body copy stays with `tiny-text`, so the two rules
|
||||
// never double-flag the same element.
|
||||
if (fontSize < floor && (isInteractive || isFurniture || dtLen <= 20)) {
|
||||
const excerpt = directText.slice(0, 40);
|
||||
findings.push({ id: 'undersized-ui-text', snippet: `${fontSize}px functional text "${excerpt}" (below ${floor}px floor)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- All-caps body text ---
|
||||
if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
|
||||
if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
||||
@@ -5121,6 +5225,8 @@ export {
|
||||
checkNumberedSectionLabels,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkNumberedSectionLabelsDOM,
|
||||
checkEmDashOveruse,
|
||||
checkEmDashOveruseDOM,
|
||||
isRepeatedTextContainer,
|
||||
collectRepeatedContainerTextFindings,
|
||||
checkRepeatedContainerTextFromDoc,
|
||||
|
||||
@@ -68,6 +68,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -97,5 +106,7 @@ export {
|
||||
GENERIC_FONTS,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
KNOWN_SERIF_FONTS,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
function arg(name, fallback = null) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
@@ -20,6 +21,183 @@ function arg(name, fallback = null) {
|
||||
return v && !v.startsWith('--') ? v : fallback;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake mode (IMPECCABLE_IMAGE_GEN_FAKE=1)
|
||||
//
|
||||
// Deterministic offline stand-in for the OpenAI call: same prompt -> identical
|
||||
// bytes, no network, no key, cost line reads $0.00. Used by the new-work smoke
|
||||
// suite so the concept/serve-question/image chain can run without spend. The
|
||||
// output renders the prompt over a 2-3 color palette hashed from the prompt,
|
||||
// plus a "SYNTHETIC COMP" corner label. SVG carries the readable text; the
|
||||
// raster (.png/.webp/.jpg) fallback carries palette stripes and stows the
|
||||
// prompt + marker in a PNG tEXt chunk so downstream stays a valid image.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FNV-1a 32-bit: tiny, dependency-free, stable across runs and platforms.
|
||||
function hash32(str) {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function hslToRgb(hDeg, s, l) {
|
||||
const h = ((hDeg % 360) + 360) % 360 / 360;
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const hue = (t) => {
|
||||
let tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||
if (tt < 1 / 2) return q;
|
||||
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||
return p;
|
||||
};
|
||||
return [hue(h + 1 / 3), hue(h), hue(h - 1 / 3)].map((c) => Math.round(c * 255));
|
||||
}
|
||||
|
||||
const toHex = ([r, g, b]) =>
|
||||
'#' + [r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// Two or three deterministic swatches derived from the prompt hash. The band
|
||||
// count itself is prompt-derived, so different prompts differ in palette.
|
||||
function palette(prompt) {
|
||||
const h = hash32(prompt);
|
||||
const base = h % 360;
|
||||
const bands = 2 + (h >>> 9) % 2; // 2 or 3
|
||||
const spread = 40 + (h >>> 3) % 120;
|
||||
const out = [];
|
||||
for (let i = 0; i < bands; i++) {
|
||||
const hue = base + i * spread;
|
||||
const light = 0.32 + ((h >>> (i * 5)) % 40) / 100; // 0.32 - 0.71
|
||||
out.push(hslToRgb(hue, 0.55, light));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function svgFake(prompt, [w, h]) {
|
||||
const colors = palette(prompt).map(toHex);
|
||||
const stops = colors
|
||||
.map((c, i) => `<stop offset="${Math.round((i / (colors.length - 1)) * 100)}%" stop-color="${c}"/>`)
|
||||
.join('');
|
||||
// Greedy word wrap tuned to the canvas width so the prompt stays legible.
|
||||
const perLine = Math.max(12, Math.floor(w / 26));
|
||||
const words = String(prompt).replace(/\s+/g, ' ').trim().split(' ');
|
||||
const lines = [];
|
||||
let cur = '';
|
||||
for (const word of words) {
|
||||
if ((cur + ' ' + word).trim().length > perLine) {
|
||||
if (cur) lines.push(cur);
|
||||
cur = word;
|
||||
} else {
|
||||
cur = (cur + ' ' + word).trim();
|
||||
}
|
||||
if (lines.length >= 10) break;
|
||||
}
|
||||
if (cur && lines.length < 11) lines.push(cur);
|
||||
const escape = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||||
const fontSize = Math.round(w / 24);
|
||||
const startY = h / 2 - ((lines.length - 1) * fontSize * 1.3) / 2;
|
||||
const text = lines
|
||||
.map((line, i) => `<text x="${w / 2}" y="${Math.round(startY + i * fontSize * 1.3)}" font-family="Helvetica, Arial, sans-serif" font-size="${fontSize}" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">${escape(line)}</text>`)
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">${stops}</linearGradient></defs>
|
||||
<rect width="${w}" height="${h}" fill="url(#g)"/>
|
||||
<rect x="0" y="0" width="${w}" height="${h}" fill="#000000" fill-opacity="0.22"/>
|
||||
${text}
|
||||
<rect x="${w - Math.round(w / 4.2)}" y="${h - Math.round(h / 16)}" width="${Math.round(w / 4.2)}" height="${Math.round(h / 16)}" fill="#000000" fill-opacity="0.55"/>
|
||||
<text x="${w - Math.round(w / 8.4)}" y="${h - Math.round(h / 32)}" font-family="Helvetica, Arial, sans-serif" font-size="${Math.round(w / 60)}" letter-spacing="2" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">SYNTHETIC COMP</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
// Minimal valid PNG: palette stripes plus a tEXt chunk carrying the marker and
|
||||
// prompt, so a .png/.webp fake stays a decodable image and still contains the
|
||||
// "SYNTHETIC" bytes downstream tools look for.
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c ^= buf[i];
|
||||
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function pngChunk(type, data) {
|
||||
const typeBuf = Buffer.from(type, 'latin1');
|
||||
const body = Buffer.concat([typeBuf, data]);
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body), 0);
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
|
||||
function pngFake(prompt, [w, h]) {
|
||||
const colors = palette(prompt); // [[r,g,b], ...]
|
||||
const bandH = Math.ceil(h / colors.length);
|
||||
// Raw image: each scanline prefixed with a 0 filter byte, RGB pixels.
|
||||
const stride = w * 3;
|
||||
const raw = Buffer.alloc(h * (stride + 1));
|
||||
for (let y = 0; y < h; y++) {
|
||||
const rowStart = y * (stride + 1);
|
||||
raw[rowStart] = 0;
|
||||
const [r, g, b] = colors[Math.min(colors.length - 1, Math.floor(y / bandH))];
|
||||
for (let x = 0; x < w; x++) {
|
||||
const p = rowStart + 1 + x * 3;
|
||||
raw[p] = r;
|
||||
raw[p + 1] = g;
|
||||
raw[p + 2] = b;
|
||||
}
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0);
|
||||
ihdr.writeUInt32BE(h, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // color type: truecolor RGB
|
||||
const idat = zlib.deflateSync(raw, { level: 9 });
|
||||
const textData = Buffer.concat([
|
||||
Buffer.from('Comment', 'latin1'),
|
||||
Buffer.from([0]),
|
||||
Buffer.from(`SYNTHETIC COMP: ${String(prompt).replace(/\s+/g, ' ').trim()}`, 'latin1'),
|
||||
]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
pngChunk('IHDR', ihdr),
|
||||
pngChunk('tEXt', textData),
|
||||
pngChunk('IDAT', idat),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function parseSize(sizeStr) {
|
||||
const m = String(sizeStr).match(/^(\d+)x(\d+)$/);
|
||||
if (!m) return [1536, 1024];
|
||||
return [Number(m[1]), Number(m[2])];
|
||||
}
|
||||
|
||||
if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
|
||||
const fakePromptFile = arg('prompt-file');
|
||||
const fakePrompt = fakePromptFile ? fs.readFileSync(fakePromptFile, 'utf8') : arg('prompt');
|
||||
const fakeOut = arg('out');
|
||||
if (!fakePrompt || !fakeOut) {
|
||||
console.error('generate-image: --prompt (or --prompt-file) and --out are required.');
|
||||
process.exit(1);
|
||||
}
|
||||
const dims = parseSize(arg('size', '1536x1024'));
|
||||
const bytes = fakeOut.endsWith('.svg')
|
||||
? Buffer.from(svgFake(fakePrompt, dims), 'utf8')
|
||||
: pngFake(fakePrompt, dims);
|
||||
fs.writeFileSync(fakeOut, bytes);
|
||||
console.log(`IMAGE: ${fakeOut} (${dims[0]}x${dims[1]}, fake synthetic comp, $0.00, no API call)`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const key = process.env.OPENAI_API_KEY;
|
||||
if (!key) {
|
||||
console.error('generate-image: OPENAI_API_KEY is not set; use the harness-native image tool instead.');
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* touchFile(cache, sessionId, filePath)
|
||||
* suppressionNotice(filePath)
|
||||
* filterFindings(findings, content, ext, config)
|
||||
* ADVISORY_RULES / isAdvisoryFinding(finding)
|
||||
* IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness)
|
||||
* matchConfiguredExtension(filePath, extensions)
|
||||
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
||||
@@ -126,6 +127,26 @@ export const IMMEDIATE_TIER_RULES = new Set([
|
||||
'design-system-font-size',
|
||||
]);
|
||||
|
||||
// ── Advisory rules ────────────────────────────────────────────────────────
|
||||
// Advisory rules are opt-in noise: the CLI reports them in a separate section
|
||||
// and they never count as failures. The design hook skips them entirely by
|
||||
// default — in both the per-edit PostToolUse pass and the Stop deep pass — so
|
||||
// the agent is never nagged about a taste call a human might make on purpose.
|
||||
// A project opts back in with `.impeccable/config.json`:
|
||||
// { "detector": { "advisoryRules": "include" } }
|
||||
// This set is the hook's own copy of the registry's `advisory: true` rules,
|
||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
||||
// self-contained and testable without loading the detector. Keep it in sync
|
||||
// with the registry (cli/engine/registry/antipatterns.mjs).
|
||||
export const ADVISORY_RULES = new Set([
|
||||
'em-dash-overuse',
|
||||
]);
|
||||
|
||||
export function isAdvisoryFinding(finding) {
|
||||
const id = finding && normalizeIgnoreRule(finding.antipattern);
|
||||
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG = Object.freeze({
|
||||
enabled: true,
|
||||
quiet: false,
|
||||
@@ -136,6 +157,9 @@ export const DEFAULT_CONFIG = Object.freeze({
|
||||
ignoreValues: [],
|
||||
extensions: [],
|
||||
perEditRules: 'immediate',
|
||||
// Advisory rules are skipped unless a project sets detector.advisoryRules to
|
||||
// "include". See ADVISORY_RULES above.
|
||||
advisoryRules: 'exclude',
|
||||
// maxFileBytes: not every generated artifact lives under a path we can
|
||||
// recognize. Committed browser bundles and vendored detector copies sit
|
||||
// next to source and run 200KB+, while genuinely authored stylesheets in
|
||||
@@ -293,6 +317,11 @@ function cloneDefaultConfig() {
|
||||
|
||||
function applyDetectorConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
// `detector.advisoryRules: "include"` opts the hook into advisory rules
|
||||
// (em-dash overuse, etc.). Any other value keeps the default "exclude".
|
||||
if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
|
||||
config.advisoryRules = raw.advisoryRules;
|
||||
}
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
@@ -755,8 +784,12 @@ export function filterFindings(findings, _content, _ext, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config.ignoreValues || []);
|
||||
// Advisory rules are skipped by default so the hook never nags about them;
|
||||
// a project opts in with detector.advisoryRules: "include".
|
||||
const includeAdvisory = (config?.advisoryRules || DEFAULT_CONFIG.advisoryRules) === 'include';
|
||||
return findings.filter((f) => {
|
||||
if (!f || typeof f !== 'object') return false;
|
||||
if (!includeAdvisory && isAdvisoryFinding(f)) return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(f, ignoreValues)) return false;
|
||||
return true;
|
||||
|
||||
@@ -43,7 +43,7 @@ function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
@@ -71,6 +71,11 @@ function cloneRawDetectionConfig() {
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
// Advisory rules are opt-in for the design hook; the CLI carries the setting
|
||||
// so config round-trips (e.g. `impeccable hooks ignore-value`) preserve it.
|
||||
if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
|
||||
config.advisoryRules = raw.advisoryRules;
|
||||
}
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
@@ -151,6 +156,9 @@ function normalizeDetectionConfigForWrite(config) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.advisoryRules === 'include' || config?.advisoryRules === 'exclude') {
|
||||
out.advisoryRules = config.advisoryRules;
|
||||
}
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
* }, ...
|
||||
* ],
|
||||
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
|
||||
* "canon": true, // adds the quiet "Play it straight" standing exit
|
||||
* // (returns {"optionId":"canon"}); direction rounds only
|
||||
* "steer": true // adds a free-text steer field returned with any answer
|
||||
* }
|
||||
*
|
||||
@@ -93,6 +95,24 @@ if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) {
|
||||
}
|
||||
}
|
||||
|
||||
// Both answer channels (blocking stdout and --wait collection) print through
|
||||
// this: the ANSWER line, then a directive to open the chosen card's imagery
|
||||
// when it has any. The card viewing happens at the moment of choice, in the
|
||||
// working turn, because a build that never reopens the chosen world's board
|
||||
// and hero calibrates on nothing.
|
||||
function printAnswer(raw) {
|
||||
console.log(`ANSWER: ${raw}`);
|
||||
try {
|
||||
const a = JSON.parse(raw);
|
||||
if (a.hero || a.board) {
|
||||
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code (download local copies first when your harness only reads files); they set the craft bar the build must reach.");
|
||||
}
|
||||
if (a.optionId === 'canon') {
|
||||
console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.');
|
||||
}
|
||||
} catch { /* raw answer */ }
|
||||
}
|
||||
|
||||
const payloadPath = arg('payload');
|
||||
const timeoutSec = Number(arg('timeout', '900'));
|
||||
const portArg = Number(arg('port', '0'));
|
||||
@@ -109,9 +129,10 @@ if (hasFlag('schema')) {
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', body: 'Fused alternate.', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
],
|
||||
reroll: true,
|
||||
canon: true,
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" is reserved. hero/board accept URLs or local paths.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board accept URLs or local paths. canon adds a quiet standing "Play it straight" action for direction decisions: the user\'s explicit door to the category standard. Include it only for visual-direction rounds; never present canon as your own recommendation.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -144,7 +165,7 @@ if (hasFlag('wait')) {
|
||||
}
|
||||
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
|
||||
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
|
||||
console.log(`ANSWER: ${collected}`);
|
||||
printAnswer(collected);
|
||||
// A re-roll keeps the table open: the server stays alive awaiting --update,
|
||||
// so only the answer file is consumed. Terminal choices clean up fully.
|
||||
let isRerollAnswer = false;
|
||||
@@ -242,12 +263,12 @@ function page() {
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${option.heroSrc || option.boardSrc ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : ''}
|
||||
<div class="media">
|
||||
${option.heroSrc ? `<img src="${esc(option.heroSrc)}" alt="">` : '<div class="hero-blank"></div>'}
|
||||
<div class="chips">${option.heroSrc ? expandChip : ''}${option.boardSrc ? flipChip('Board') : ''}</div>
|
||||
</div>
|
||||
${option.heroSrc || option.boardSrc ? `<div class="media">
|
||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||
<div class="chips">${expandChip}${option.boardSrc && option.heroSrc ? flipChip('Board') : ''}</div>
|
||||
</div>` : ''}
|
||||
<div class="body">
|
||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||
<h2>${esc(option.label)}</h2>
|
||||
@@ -255,7 +276,7 @@ function page() {
|
||||
<button class="choose" data-id="${esc(option.id)}">Build this</button>
|
||||
</div>
|
||||
</div>
|
||||
${option.boardSrc ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
${option.boardSrc && option.heroSrc ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
<div class="media back-media">
|
||||
<img src="${esc(option.boardSrc)}" alt="">
|
||||
<div class="chips">${expandChip}${flipChip('Hero')}</div>
|
||||
@@ -328,6 +349,10 @@ function page() {
|
||||
.card:hover .face.lead { border-color: var(--ks-kinpaku); }
|
||||
@media (prefers-reduced-motion: reduce) { .card-inner { transition: none; } }
|
||||
.kicker { position: absolute; z-index: 2; top: 12px; left: 12px; padding: 4px 10px; background: var(--ks-kinpaku); color: var(--ks-dark-ink); font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .24em; text-transform: uppercase; border-radius: 4px; }
|
||||
/* Text-only card: a grounded direction with no rendered card drops the media
|
||||
region entirely instead of reserving a blank 16:9 void. */
|
||||
.face.text-only .kicker { position: static; align-self: flex-start; margin: 14px 0 0 14px; }
|
||||
.face.text-only .body { padding-top: 12px; }
|
||||
.media { position: relative; width: 100%; aspect-ratio: 16/9; flex: none; }
|
||||
.media img { width: 100%; height: 100%; object-fit: cover; display: block; background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
|
||||
.face.back { background: var(--ks-lacquer-deep); }
|
||||
@@ -350,6 +375,11 @@ function page() {
|
||||
#reroll { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
#reroll svg { width: 15px; height: 15px; }
|
||||
/* The quiet exit: always available, never argued with, visually subordinate
|
||||
to the dealt cards and the re-roll so it reads as the user's own door,
|
||||
not a recommendation. */
|
||||
#canon { align-self: center; padding: 0 4px; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: inherit; opacity: .45; background: transparent; border: none; border-bottom: 1px dotted currentColor; cursor: pointer; transition: opacity .2s ease; }
|
||||
#canon:hover { opacity: .85; }
|
||||
.card.skeleton .media { background: var(--ks-graphite); }
|
||||
.shimmer { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
|
||||
.card.skeleton .line { height: 11px; border-radius: 4px; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
|
||||
@@ -384,6 +414,7 @@ function page() {
|
||||
<footer>
|
||||
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
|
||||
${payload.reroll ? '<button id="reroll"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg><span>Re-roll</span></button>' : ''}
|
||||
${payload.canon ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
|
||||
</footer>
|
||||
<script>
|
||||
const steer = () => document.getElementById('steer')?.value || '';
|
||||
@@ -454,6 +485,7 @@ function page() {
|
||||
const closeLightbox = () => { lightbox.classList.remove('open'); setTimeout(() => { lightbox.hidden = true; }, 250); };
|
||||
lightbox.addEventListener('click', closeLightbox);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
||||
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
|
||||
document.getElementById('reroll')?.addEventListener('click', async () => {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
|
||||
const grid = document.querySelector('.grid');
|
||||
@@ -518,7 +550,11 @@ const server = http.createServer((req, res) => {
|
||||
if (imageMatch) {
|
||||
const abs = localImages[Number(imageMatch[1])];
|
||||
if (!abs) { res.writeHead(404); res.end(); return; }
|
||||
const type = abs.endsWith('.webp') ? 'image/webp' : abs.endsWith('.png') ? 'image/png' : 'image/jpeg';
|
||||
const type = abs.endsWith('.webp') ? 'image/webp'
|
||||
: abs.endsWith('.png') ? 'image/png'
|
||||
: abs.endsWith('.svg') ? 'image/svg+xml'
|
||||
: abs.endsWith('.gif') ? 'image/gif'
|
||||
: 'image/jpeg';
|
||||
res.writeHead(200, { 'content-type': type });
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
return;
|
||||
@@ -531,13 +567,18 @@ const server = http.createServer((req, res) => {
|
||||
res.end('{"ok":true}');
|
||||
let parsed = {};
|
||||
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
|
||||
const answer = JSON.stringify({ optionId: parsed.optionId ?? null, steer: parsed.steer ?? '' });
|
||||
const chosen = options.find((o) => o.id === parsed.optionId);
|
||||
const answer = JSON.stringify({
|
||||
optionId: parsed.optionId ?? null,
|
||||
steer: parsed.steer ?? '',
|
||||
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||
});
|
||||
const isReroll = parsed.optionId === 'reroll';
|
||||
if (detachedKey) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
|
||||
} else {
|
||||
console.log(`ANSWER: ${answer}`);
|
||||
printAnswer(answer);
|
||||
}
|
||||
// A re-roll in detached mode keeps the table open: the client shows a
|
||||
// loading hand and reloads when --update delivers the next round.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: impeccable-asset-producer
|
||||
description: Produces clean reusable raster assets from approved Impeccable mock references without redesigning the direction.
|
||||
tools: Read, Write, Edit, Bash, Glob, Grep
|
||||
model: inherit
|
||||
effort: medium
|
||||
maxTurns: 12
|
||||
---
|
||||
# Impeccable Asset Producer
|
||||
|
||||
You are the asset production agent for Impeccable craft.
|
||||
|
||||
Your job is production cleanup, not new art direction. Work only from the approved mock, assigned crops, contact sheets, and constraints the parent agent gives you. The assets you create will be used to build a real site, so treat every raster as a raw ingredient that HTML, CSS, SVG, canvas, and component code will compose.
|
||||
|
||||
## Core Rule
|
||||
|
||||
Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster.
|
||||
|
||||
## Input Contract
|
||||
|
||||
Expect:
|
||||
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
Use defaults unless contradicted:
|
||||
|
||||
- `.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. Do not use small full-page mock crop size as the default shipping size.
|
||||
- Remove UI text, navigation, buttons, labels, and body copy by default.
|
||||
- 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 to the asset.
|
||||
- Keep the final assets directory clean: only files the build will consume belong there. Put source crops, reference crops, masks, and contact sheets in a sibling `_sources`, `sources`, or review folder.
|
||||
|
||||
Ask blockers once, globally. Missing source path/crops or output directory blocks production. Exact dimensions, compression targets, retina variants, and format preferences do not block; choose defaults and report them.
|
||||
|
||||
## 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`: can ship as a crop, format conversion, compression pass, or sourced replacement with no generative cleanup.
|
||||
- `semantic`: build in HTML/CSS/SVG/canvas, no raster output.
|
||||
3. Treat full-page mock crops as references, not production-resolution source assets. Put a role in `direct` only when the provided source is already a clean, sufficiently large source asset with no semantic text or presentation chrome.
|
||||
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 semantic HTML/CSS/SVG recommendation if raster is wrong.
|
||||
6. Treat every crop as binding reference. Use the harness's native image tool by default when generation or editing is needed<codex> (the imagegen skill's built-in `image_gen` path)</codex>; 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.
|
||||
10. Compare each output against its source crop. If a review/QA tool is available, run it before the final manifest, then retry each major/fatal finding once before finalizing.
|
||||
|
||||
Use `direct` only for provided source assets that can already ship after crop tightening, conversion, compression, or naming. Do not ship a small crop from the full-page mock as `direct` just because it looks close.
|
||||
|
||||
Use `texture/pattern extraction` only when the source region is already clean enough to sample as texture. If UI, cards, labels, headings, body copy, or footer chrome must be removed to make a reusable texture or background, classify it as crop-derived cleanup or clean-plate work.
|
||||
|
||||
Use `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. Only ship a screenshot raster 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: name the DOM/component layers, CSS-owned visual treatment, SVG/canvas/icon-library pieces, responsive behavior, and which nearby produced raster assets it should compose with. For logos and icons, prefer inline SVG/vector or icon-library implementation unless the parent provides a production logo raster.
|
||||
|
||||
For transparency, prefer true alpha output when the tool supports it. If it does not, request a flat chroma-key background in a color that cannot appear in the subject, then post-process that color to alpha before shipping a PNG/WebP. Do not ship the keyed background as the final asset.
|
||||
|
||||
## 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 a chroma-key workflow by default<codex> (the imagegen skill's built-in-first path)</codex>: generate on a flat color that cannot appear in the subject, then post-process to alpha; use true native transparency only when the tool supports it or the parent authorizes it.
|
||||
|
||||
## Output Contract
|
||||
|
||||
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` must be a concrete build handoff, not a short explanation that no asset was produced. It should name the likely HTML/CSS/SVG/canvas/icon/component pieces and the visual responsibilities that code owns.
|
||||
|
||||
`qa_status` must be `accepted`, `needs_parent_review`, or `blocked`. Use `accepted` only after visual comparison passes. Use `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. Use `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. Do not repeat missing inputs in every row; per-asset rows should 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.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: impeccable-finish-reviewer
|
||||
description: Reviews a finished Impeccable build against its direction contract, persistence requirements, and the chosen world's quality bar, returning an ordered list of material fixes.
|
||||
tools: Read, Bash, Glob, Grep
|
||||
model: inherit
|
||||
effort: high
|
||||
maxTurns: 12
|
||||
---
|
||||
# Impeccable Finish Reviewer
|
||||
|
||||
You are the finishing reviewer for an Impeccable build: fresh eyes on a done artifact, outside the build thread's attention gravity. You do not edit anything; the parent agent applies your fixes.
|
||||
|
||||
## Input Contract
|
||||
|
||||
Expect: the original request; the confirmed user answers; the artifact path(s); the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md and DESIGN.md paths; existing hook or detector findings; the chosen world's QUALITY BAR card paths and approved comp paths when they exist; screenshot path(s) when available. When the harness can view images, open the card, the comp, and the screenshot before judging.
|
||||
|
||||
## Checks, in order
|
||||
|
||||
1. **Persistence.** On a new or replacement world: PRODUCT.md and DESIGN.md exist, and DESIGN.md matches the built world. A missing or mismatched file is the first material fix, ahead of any craft point.
|
||||
2. **Ceiling.** Against the QUALITY BAR card and the approved comp, name the world's native devices the build left unused: frame, depth, lettering treatment, ornament density, motion. Compare commitment and finish, never composition; the card is a bar, not a layout.
|
||||
3. **Contract, promise by promise.** For each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport: what would a visitor describe an hour later, and is it the thesis or a mood?
|
||||
4. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions.
|
||||
|
||||
Do not run a second detector pass; mechanical findings belong to the parent's hooks.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Return exactly four sections: `persistence` (pass/fail with specifics), `ceiling` (the unused native devices, or "reached"), `material_fixes` (ordered, most material first, each one line tied to a check or contract promise, at most eight), and `keep` (one line naming what must not be diluted while fixing). No praise, no summary prose.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 4.0.0
|
||||
version: 4.0.1
|
||||
user-invocable: true
|
||||
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
|
||||
license: Apache 2.0
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Codex: Surface Probes & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) only when the harness has native image generation and a substantial, high-fidelity surface would benefit from seeing the shortlisted concept before code. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate the smallest useful probe set
|
||||
|
||||
Generate one to three high-fidelity north-star comps using the native image-generation capability. Base them on the real content and the surface concepts already developed with the user.
|
||||
|
||||
- When the user shortlisted multiple concepts, show one clear expression of each.
|
||||
- When one concept is already selected, vary only the structural uncertainty that the image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||
- Show enough beyond the opening moment to prove the concept can govern the whole requested surface.
|
||||
- Do not generate a palette artifact, ask new atmosphere questions, introduce a different type voice, or invent a new motif. If the committed world cannot support the concept, return to the concept shortlist rather than changing the world.
|
||||
|
||||
Treat each comp as a direction test, not a screenshot specification. Core UI text, responsive behavior, accessibility, semantics, and interaction states remain implementation responsibilities.
|
||||
|
||||
## One approval point
|
||||
|
||||
Show the probes together and ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
After approval, summarize the composition and the parts of the comp that must not be literalized. Return to new-work.md, record the direction contract from the approved surface concept, then build.
|
||||
|
||||
## Inventory implementation fidelity
|
||||
|
||||
Before building, inventory the approved comp's major visible ingredients 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.
|
||||
|
||||
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies. If the concept depends on a photograph, architectural scene, product object, portrait, or other raster-native material, do not silently replace it with generic CSS scenery.
|
||||
|
||||
Treat the comp as a north star, not something to trace. 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
|
||||
|
||||
When clean raster ingredients are required and a scoped subagent is available and authorized, use `impeccable_asset_producer`. Give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets with the native image-generation capability in the current thread.
|
||||
|
||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||
@@ -44,9 +44,11 @@ The script assigns which structure gets built: your top-ranked structure is what
|
||||
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, the notation, publications, identity programs, data graphics, and interfaces it reads daily, not only its physical objects; a nameable abstract system (a school of poster, a documentation standard, a data-graphic tradition) 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; the audience's world is larger than that, so 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 .grok/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Offer re-roll with an optional one-line steer instead of a ranked menu; a lineup invites the safest card. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Pick the channel by capability, not by habit: can you put a page in front of the user, through an in-app browser or by opening a browser window on their machine outside your harness? If yes, present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll plus steer enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll, then run `node .grok/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, preferring the in-app browser when the harness has one, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. If no, because the session is headless, CI, an eval worker, or a remote shell with no display, skip the page and put the same decision through the structured question tool; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the one or two fused challengers that survived the weighing as named alternates with a one-line case each, plus re-roll with an optional one-line steer. What you never present is a ranked menu of your own grounded candidates; a lineup of those invites the safest card, while dealt challengers carry no such rut. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list also carries the standing exit as its last option.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. When the harness can view images, open the QUALITY BAR board and hero the seed prints for the world you build (when it only reads local images, download the card to a temp file first and view that): they set the craft level the build must reach, the finish, commitment, and art direction of a rendered reference, and never dictate the composition; your surface serves this product.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Run `node .grok/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images for exactly this reason (download to a temp file when the harness only reads local images). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
|
||||
|
||||
Every direction the roll can land on must already be viable: every relationship and claim it visualizes true, a real palette and component family, a distinctive composition with one product-specific experience, workable at full-surface scale within the available assets, tools, and performance budget. A candidate that fails on truth is replaced before the roll, never rescued by it. Truth binds claims, not demonstrations: in greenfield work, author whatever illustrative material the concept needs at full fidelity, label it synthetic wherever a visitor could mistake it for the real thing, and hand the user the list of what to replace with real material. What stays uninventable are commercial and factual claims: prices, customers, benchmarks, endpoints, capabilities the product does not have. Refusing a bold direction because its demonstration data does not exist yet is the timidity reflex wearing honesty's clothes.
|
||||
|
||||
@@ -62,10 +64,12 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su
|
||||
|
||||
## 5. Record the decision
|
||||
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. 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. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, the staging chosen for it, and the seed key the script printed; when the seed dealt stagings, choosing the world does not silently discharge the staging decision, so name the one you commit to. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
|
||||
|
||||
When a new or replacement world is chosen, DESIGN.md is part of recording the decision, not an aftercare step: write it at the appropriate project or app boundary using [document.md](document.md) before the first build edit lands, in the same working stretch as the direction contract. Record only durable system rules; exact tokens may remain provisional until the first build establishes them, and you update the file when the build settles them. A new world shipped with no DESIGN.md is an incomplete run, exactly as a missing PRODUCT.md is; the finishing review checks the file exists and matches the built world. An ordinary extension does not rewrite DESIGN.md.
|
||||
|
||||
Two observed ways a DESIGN.md rule goes wrong: a prohibition that bans a device the world itself uses natively (arcade lettering lives on its offset shadow), and a token added so a hook finding stops firing. Check every prohibition against the world's own materials; a value earns its place by the world and by legibility, never by silencing a check.
|
||||
|
||||
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
|
||||
|
||||
`node .grok/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
|
||||
@@ -74,7 +78,7 @@ If the work establishes durable strategy for a route or artifact, read its exist
|
||||
|
||||
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
|
||||
|
||||
Visualize before you build whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports: render the chosen direction as a design-system board and a first-surface mock, correct material drift between mock and intent, then build. Seeing the direction first measurably strengthens the result. [codex.md](codex.md) carries the deep native-generation flow; the mock is a selection aid, not authority.
|
||||
Whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options rendered and put before the user for approval. This step is proven to produce the most compositional and ambitious work.
|
||||
|
||||
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
|
||||
|
||||
@@ -84,8 +88,7 @@ Build the assigned direction, not a safer interpretation of it. The form supplie
|
||||
|
||||
- **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. 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; never invent prices, customers, benchmarks, or capabilities.
|
||||
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, titles, 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, and no section is omittable. When a commercial claim stayed unanswered, ship a clearly marked placeholder value and hand the user the replacement list; a section thinned or dropped for missing truth is the asset gap wearing honesty's clothes. Decoration compensating for missing content, gradients, glass, borders, icon tiles where an authored asset belongs, is the same gap wearing chrome's.
|
||||
- **Generate the imagery the build needs.** When any image generation is available, a harness-native tool or the API fallback context.mjs reports, generating the design's imagery is part of building: heroes, covers, demonstration thumbnails, textures, marks. Gray boxes and stock-styled placeholders are not a fallback while generation is one command away; state the cost once before the first render and batch what the surface needs.
|
||||
- **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 that before a line of code exists. 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.
|
||||
@@ -97,4 +100,4 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
|
||||
|
||||
Inspect desktop and mobile, critique the render against the user's request, the direction contract, and DESIGN.md, fix material gaps, and re-inspect. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary. After a first implementation of a new world, update DESIGN.md with the exact tokens and behaviors that survived the build.
|
||||
|
||||
When the harness can run a separate agent, this review belongs there, not in the build thread: give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, and existing hook findings. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector.
|
||||
Spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex), and give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, existing hook findings, and the QUALITY BAR card and approved comp paths. This review never runs inside the build thread; a harness with no subagents runs it as its own fresh pass, after stepping fully out of the build context. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Its second check is the ceiling: against the chosen world's QUALITY BAR card and the approved comp, name the native devices the build left unused, frame, depth, lettering, ornament density; floor rigor is very good at disguising an unreached card. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Visualize: Direction Comps & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate three compositional options
|
||||
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building.
|
||||
|
||||
- When the user shortlisted multiple concepts, spread the three across them.
|
||||
- When one direction is committed, vary the structural uncertainty an image can resolve: topology, sequence, density, hierarchy, focal composition, or interaction framing.
|
||||
- Show enough beyond the opening moment to prove the concept can govern the whole requested surface.
|
||||
- Do not generate a palette artifact, ask new atmosphere questions, introduce a different type voice, or invent a new motif. If the committed world cannot support the concept, return to the concept shortlist rather than changing the world.
|
||||
|
||||
Treat each comp as a direction test, not a screenshot specification. Core UI text, responsive behavior, accessibility, semantics, and interaction states remain implementation responsibilities.
|
||||
|
||||
## One approval point
|
||||
|
||||
Show the three together: in the harness when it can display images, otherwise on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero). Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
After approval, summarize the composition and the parts of the comp that must not be literalized. Return to new-work.md, record the direction contract from the approved surface concept, then build.
|
||||
|
||||
## Inventory implementation fidelity
|
||||
|
||||
Before building, inventory the approved comp's major visible ingredients 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.
|
||||
|
||||
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies. If the concept depends on a photograph, architectural scene, product object, portrait, or other raster-native material, do not silently replace it with generic CSS scenery.
|
||||
|
||||
Treat the comp as a north star, not something to trace. 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
|
||||
|
||||
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread with whatever generation exists, the native tool or generate-image.mjs.
|
||||
|
||||
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
|
||||
@@ -79,6 +79,13 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_DIR = process.env.IMPECCABLE_CATALOG_DIR || here;
|
||||
const API_BASE = (process.env.IMPECCABLE_API_URL || 'https://impeccable.style/api').replace(/\/$/, '');
|
||||
const API_TIMEOUT_MS = Number(process.env.IMPECCABLE_API_TIMEOUT || 4000);
|
||||
// All API calls in one seed run share a single deadline so an unreachable
|
||||
// network degrades after one timeout total, never one timeout per call.
|
||||
let apiDeadline = null;
|
||||
function apiBudgetMs() {
|
||||
if (apiDeadline === null) apiDeadline = Date.now() + API_TIMEOUT_MS;
|
||||
return Math.max(0, apiDeadline - Date.now());
|
||||
}
|
||||
|
||||
const localStates = new Map();
|
||||
function loadLocal(catalogDir = CATALOG_DIR) {
|
||||
@@ -120,9 +127,15 @@ async function fetchRoll({ scope, key, mode, reroll }) {
|
||||
const params = new URLSearchParams({ scope, key, reroll: String(reroll) });
|
||||
if (mode) params.set('mode', mode);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal });
|
||||
// Race the budget explicitly: abort signals do not reliably cancel the
|
||||
// TCP connect phase, so a blackholed route would otherwise stall ~10s.
|
||||
const response = await Promise.race([
|
||||
fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal }),
|
||||
new Promise(resolveTimeout => setTimeout(() => resolveTimeout(null), apiBudgetMs())),
|
||||
]);
|
||||
if (!response) return null;
|
||||
if (!response.ok) return null;
|
||||
const roll = await response.json();
|
||||
if (!Array.isArray(roll.challengers) || roll.challengers.length === 0) return null;
|
||||
@@ -143,7 +156,7 @@ function telemetryDisabled() {
|
||||
export async function pingChosen({ chosenId, key, scope, mode }) {
|
||||
if (telemetryDisabled() || !chosenId) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
await fetch(`${API_BASE}/chosen`, {
|
||||
method: 'POST',
|
||||
@@ -445,7 +458,10 @@ ASSIGNED INDEX: ${buildIndex}
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
No challengers this run: the roll service was unreachable and no local
|
||||
catalog exists. Proceed with the grounded candidates alone; the assignment
|
||||
catalog exists. A sandboxed shell without network egress is the most common
|
||||
cause: if this harness can rerun the command with network access granted,
|
||||
do that once before proceeding. Otherwise proceed with the grounded
|
||||
candidates alone; the assignment
|
||||
above still applies at full strength. Tell the user plainly that this roll
|
||||
ran degraded, with no challengers and no quality-bar boards; do not present
|
||||
the outcome as a full roll. A degraded roll changes the cards, not the
|
||||
@@ -543,4 +559,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
// A raced-out fetch may still hold a socket; exit explicitly so the CLI
|
||||
// never lingers on a dead network path after output is written.
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
|
||||
@@ -1223,6 +1223,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -1541,6 +1545,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadDesignSystemForCwd } from '../design-system.mjs';
|
||||
import { loadDesignSystemForTarget } from '../design-system.mjs';
|
||||
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs';
|
||||
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
|
||||
import { detectHtml } from '../engines/static-html/detect-html.mjs';
|
||||
@@ -27,9 +28,37 @@ function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
// Local filesystem path behind a file:// URL, or null when it can't be mapped.
|
||||
function fileUrlToLocalPath(url) {
|
||||
try {
|
||||
return fileURLToPath(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Advisory findings are detected but never treated as failures: they list in a
|
||||
// separate, visually dimmed section, are excluded from the failure count that
|
||||
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
|
||||
// filter. Every advisory finding carries the flag (stamped by the registry via
|
||||
// findings.mjs).
|
||||
function isAdvisory(finding) {
|
||||
return finding && finding.advisory === true;
|
||||
}
|
||||
|
||||
function partitionAdvisory(findings) {
|
||||
const primary = [];
|
||||
const advisory = [];
|
||||
for (const f of findings) (isAdvisory(f) ? advisory : primary).push(f);
|
||||
return { primary, advisory };
|
||||
}
|
||||
|
||||
// ANSI dim, when stderr is a TTY. Advisory output is chrome, so keep it quiet.
|
||||
function dim(text) {
|
||||
return process.stderr.isTTY ? `\x1b[2m${text}\x1b[0m` : text;
|
||||
}
|
||||
|
||||
function formatFindingsBody(findings) {
|
||||
const grouped = {};
|
||||
for (const f of findings) {
|
||||
if (!grouped[f.file]) grouped[f.file] = [];
|
||||
@@ -44,7 +73,28 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatAdvisorySection(advisory) {
|
||||
if (!advisory || advisory.length === 0) return '';
|
||||
const lines = [`\n${dim('── Advisory (not counted as failures) ──')}`];
|
||||
for (const line of formatFindingsBody(advisory)) lines.push(dim(line));
|
||||
lines.push(dim(`\n${advisory.length} advisory note${advisory.length === 1 ? '' : 's'}. Suppress with --no-advisory.`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Text/JSON formatter. `findings` is the full set; advisory items are separated
|
||||
// out into their own section and excluded from the failure summary count. JSON
|
||||
// output keeps every finding (each advisory one flagged) in a single array.
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
const { primary, advisory } = partitionAdvisory(findings);
|
||||
const out = [...formatFindingsBody(primary)];
|
||||
out.push(`\n${formatFindingSummary(primary.length)}`);
|
||||
const advisorySection = formatAdvisorySection(advisory);
|
||||
if (advisorySection) out.push(advisorySection);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -52,7 +102,11 @@ function formatFindings(findings, jsonMode) {
|
||||
// Stdin handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleStdin(options = {}) {
|
||||
// `optionsFor` maps a local path to scan options carrying that path's own
|
||||
// project design system (or base options when null). Falls back to a plain
|
||||
// object so direct/legacy callers still work.
|
||||
async function handleStdin(optionsFor = () => ({})) {
|
||||
const resolve = typeof optionsFor === 'function' ? optionsFor : () => optionsFor;
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const input = Buffer.concat(chunks).toString('utf-8');
|
||||
@@ -60,11 +114,12 @@ async function handleStdin(options = {}) {
|
||||
const parsed = JSON.parse(input);
|
||||
const fp = parsed?.tool_input?.file_path;
|
||||
if (fp && fs.existsSync(fp)) {
|
||||
const options = resolve(fp);
|
||||
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
|
||||
? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options);
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return detectText(input, '<stdin>', options);
|
||||
return detectText(input, '<stdin>', resolve(null));
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +155,14 @@ Options:
|
||||
ignore comments, or DESIGN.md
|
||||
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
|
||||
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
|
||||
--no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)
|
||||
--help Show this help message
|
||||
|
||||
Advisory findings:
|
||||
Some rules are advisory: detected and listed in a separate section, but never
|
||||
counted as failures and never changing the exit code. They stay out of the
|
||||
failure count so they never block automation. --no-advisory hides them.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -139,6 +200,7 @@ async function detectCli() {
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
const noAdvisory = args.includes('--no-advisory');
|
||||
// --fast (regex-only) is deprecated: since the jsdom removal, the static
|
||||
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
|
||||
// only loses coverage for no real speed win. Accept the flag for back-compat
|
||||
@@ -199,14 +261,23 @@ async function detectCli() {
|
||||
process.exit(1);
|
||||
}
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
|
||||
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
|
||||
// apply by default. `--no-config` (raw scan) and the dedicated
|
||||
// `--no-inline-ignores` both turn them off.
|
||||
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
|
||||
const scanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
if (viewport) scanOptions.viewport = viewport;
|
||||
const baseScanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (viewport) baseScanOptions.viewport = viewport;
|
||||
// DESIGN.md must resolve from EACH scan target's own project root, not from
|
||||
// process.cwd(): scanning project B's files from inside project A applied A's
|
||||
// design rules (cross-project contamination). Resolve per target, memoized by
|
||||
// resolved project root so a multi-file scan pays the read once per project.
|
||||
// A target with no project marker above it gets no design system (never cwd's).
|
||||
const designSystemCache = new Map();
|
||||
const scanOptionsFor = (localPath) => {
|
||||
if (!designSystemEnabled || !localPath) return baseScanOptions;
|
||||
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
||||
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
|
||||
};
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -214,7 +285,7 @@ async function detectCli() {
|
||||
let allFindings = [];
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptions);
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
} else {
|
||||
const paths = targets.length > 0 ? targets : [process.cwd()];
|
||||
// file:// URLs get the same Puppeteer-rendered pass as http(s) — the
|
||||
@@ -228,10 +299,17 @@ async function detectCli() {
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (urlRe.test(target)) {
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
// process.cwd()'s.
|
||||
const urlOptions = /^file:/i.test(target)
|
||||
? scanOptionsFor(fileUrlToLocalPath(target))
|
||||
: baseScanOptions;
|
||||
try {
|
||||
const scanner = browserDetector
|
||||
? (url) => browserDetector.detectUrl(url, scanOptions)
|
||||
: (url) => detectUrl(url, scanOptions);
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
@@ -297,11 +375,14 @@ async function detectCli() {
|
||||
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
let fileFindings;
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
fileFindings = await detectHtml(file, scanOptions);
|
||||
fileFindings = await detectHtml(file, fileOptions);
|
||||
} else {
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions);
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, fileOptions);
|
||||
}
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
@@ -316,10 +397,11 @@ async function detectCli() {
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
allFindings.push(...await detectHtml(resolved, scanOptions));
|
||||
allFindings.push(...await detectHtml(resolved, fileOptions));
|
||||
} else {
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions));
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,12 +412,24 @@ async function detectCli() {
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
// --no-advisory drops advisory findings before any output or exit-code math.
|
||||
if (noAdvisory) allFindings = allFindings.filter((f) => !isAdvisory(f));
|
||||
|
||||
// The exit code and failure count reflect non-advisory findings only. An
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else if (quietMode) {
|
||||
process.stderr.write(formatFindingSummary(primary.length) + '\n');
|
||||
if (advisory.length > 0) {
|
||||
process.stderr.write(dim(`${advisory.length} advisory note${advisory.length === 1 ? '' : 's'} (not counted).`) + '\n');
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { finding } from './findings.mjs';
|
||||
@@ -7,6 +8,11 @@ import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs';
|
||||
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// Files/dirs whose presence marks a directory as a project root. Mirrors the
|
||||
// walk-up semantics of skill/scripts/context.mjs (`resolveProject`), which the
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
@@ -469,6 +475,62 @@ function loadDesignSystemForCwd(cwd = process.cwd()) {
|
||||
});
|
||||
}
|
||||
|
||||
// Directory to begin the project-root walk from, given a scan target that may
|
||||
// be a file or a directory (and may not exist yet).
|
||||
function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
return fs.statSync(abs).isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
// Nonexistent path: treat an extension-bearing leaf as a file.
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
// Returns { dir, hasDesign } for the stopping directory, or null when the walk
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the design system that governs a specific scan target, by walking up
|
||||
// from the target's own location — never process.cwd(). Scanning project B's
|
||||
// files from inside project A applies B's DESIGN.md (or none), not A's.
|
||||
//
|
||||
// Pass a `cache` Map to memoize by resolved design root across a multi-file
|
||||
// scan; a target with no design root above it resolves to null.
|
||||
export function loadDesignSystemForTarget(targetPath, { cache, cwd = process.cwd() } = {}) {
|
||||
const startDir = designSystemStartDir(targetPath, cwd);
|
||||
const found = findDesignRoot(startDir);
|
||||
const key = found ? `root:${found.dir}` : '\0none';
|
||||
if (cache && cache.has(key)) return cache.get(key);
|
||||
const loaded = found?.hasDesign ? loadDesignSystemForCwd(found.dir) : null;
|
||||
if (cache) cache.set(key, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function isAllowedFont(font, designSystem) {
|
||||
if (!font || GENERIC_FONTS.has(font)) return true;
|
||||
if (!designSystem?.hasFonts) return true;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user