mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae56d719af | ||
|
|
83e8b4645c | ||
|
|
4f08ff3bfc | ||
|
|
9d723f39df | ||
|
|
d6c2442dbe | ||
|
|
816ffe92d0 | ||
|
|
c0e7f2d778 | ||
|
|
65de2d294b | ||
|
|
07663f5fbd | ||
|
|
c489335799 | ||
|
|
ddd23b1807 | ||
|
|
710aa57637 | ||
|
|
9b404edff5 | ||
|
|
504b8f2a22 | ||
|
|
628509b948 | ||
|
|
121602079c | ||
|
|
3b96bd5090 | ||
|
|
fde4a3ee71 | ||
|
|
7d907bbb14 | ||
|
|
ebc63f071a | ||
|
|
d4e1b0902f | ||
|
|
0e5c6cbe17 | ||
|
|
bd25359748 | ||
|
|
9e8b9bc389 | ||
|
|
9632b33f6c | ||
|
|
9b055c82d7 | ||
|
|
74ca4dd7a8 | ||
|
|
76b9aaf021 | ||
|
|
1d5e05785b | ||
|
|
c8b5395e79 | ||
|
|
ea90b23bc8 | ||
|
|
d14711ae3d | ||
|
|
64dd60a78a | ||
|
|
6129744410 | ||
|
|
e2421aff43 | ||
|
|
6b7f62979b | ||
|
|
ac4c3200db | ||
|
|
89368a2430 | ||
|
|
e36833ce21 | ||
|
|
d6ea967ea1 | ||
|
|
6c837bd7d4 | ||
|
|
a528992b98 | ||
|
|
f66eace20d | ||
|
|
3a26dcb809 | ||
|
|
37be3fa36b | ||
|
|
fbf3ee01b1 | ||
|
|
68f3d18568 | ||
|
|
3b2566d4d1 | ||
|
|
f1b7111503 | ||
|
|
357f358050 | ||
|
|
d4aacaccfd | ||
|
|
def69e157b | ||
|
|
d23fa1c882 | ||
|
|
520a55547e | ||
|
|
82234515e0 | ||
|
|
94e957d7fc | ||
|
|
92c857a9ef | ||
|
|
5ce4a5c6b5 |
@@ -15,7 +15,7 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh
|
||||
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ An open direction round owns the word first: "bolder" said while a direction dec
|
||||
|
||||
## Scope is sovereign
|
||||
|
||||
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, stop and STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. before expanding it, naming the exact addition and the job it would do.
|
||||
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, do not expand it on your own. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Name the exact addition and the job it would do.
|
||||
|
||||
## Why it reads flat
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ Resolve one stable target, run two independent assessments, synthesize a design
|
||||
- Viewable targets require browser inspection when available.
|
||||
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
|
||||
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
|
||||
- The question is the LAST thing in the response. Write the entire report out first, then ask; nothing follows the question. Prose emitted after a structured question is withheld until the user answers it, so a report written after the question reads as if the critique never ran.
|
||||
- A run that ends with neither the targeted questions nor a literal `Questions skipped: <reason>` line is an incomplete run. The report is not the finish; the close is.
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -192,6 +194,14 @@ Codex Run Notes are final-chat only. Do not include this section in the persiste
|
||||
- Prioritize ruthlessly. If everything is important, nothing is.
|
||||
- Don't soften criticism. Developers need honest feedback to ship great design.
|
||||
|
||||
### Deliver the Report
|
||||
|
||||
Write the full report into the chat response now, before any persistence work. This is the deliverable; everything below it is bookkeeping.
|
||||
|
||||
Do this first because the alternative is the most common way this command fails: the report gets composed once, straight into the persistence heredoc, and the run ends with a perfect archive nobody has read. Composing it into a file is not delivering it. If the report exists only in `.impeccable/critique/`, the run produced nothing.
|
||||
|
||||
Persistence is not the end of the run. After it, the response continues with the trend line and the close.
|
||||
|
||||
### Persist the Snapshot
|
||||
|
||||
Once the report above is finalized, write it to `.impeccable/critique/` so the user can refer back, and so `$impeccable polish` can pick up the priority issues without a copy-paste.
|
||||
@@ -200,6 +210,8 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
|
||||
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, design-specificity verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
|
||||
|
||||
This is a copy of the report you already delivered above, for later commands to read. It is not delivery. If you find yourself composing the report for the first time inside this heredoc, you have skipped Deliver the Report; go back and send it.
|
||||
|
||||
Codex: exclude Run Notes from the temp body file; Run Notes are final-chat only because persistence, trend read, and temp cleanup happen after the snapshot write.
|
||||
|
||||
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
|
||||
@@ -226,12 +238,16 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
|
||||
If this is the first run for the slug, the trend is just one score; say so: "First run for this target, no trend yet."
|
||||
|
||||
6. **Close the run.** Go to Ask the User below and emit the questions, or the `Questions skipped: <reason>` line when the count allows it. The run is not complete until you do. Persistence is bookkeeping and cleanup is not an ending; stopping here leaves the user with a report and no way forward, and leaves `$impeccable polish` with no priorities to inherit.
|
||||
|
||||
This is fire-and-forget. Do not show the user the helper's JSON output; only the human-readable trend line and the written path. Failures here should not block the rest of the flow; print the error and move on.
|
||||
|
||||
### Ask the User
|
||||
|
||||
**After presenting findings**, use targeted questions based on what was actually found. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. These answers will shape the action plan.
|
||||
|
||||
Ask in the same message that carries the report, with the report written out first and the question last. Do not split the two across turns: a turn that ends on the report is a turn that ends, and the questions never arrive. Order within the message is what matters, because prose emitted after a structured question is withheld until the user answers.
|
||||
|
||||
Ask questions along these lines (adapt to the specific findings; do NOT ask generic questions):
|
||||
|
||||
1. **Priority direction**: Based on the issues found, ask which category matters most to the user right now. For example: "I found problems with visual hierarchy, color usage, and information overload. Which area should we tackle first?" Offer the top 2-3 issue categories as options.
|
||||
@@ -246,9 +262,9 @@ Ask questions along these lines (adapt to the specific findings; do NOT ask gene
|
||||
- Every question must reference specific findings from the report. Never ask generic "who is your audience?" questions.
|
||||
- Keep it to 2-4 questions maximum. Respect the user's time.
|
||||
- Offer concrete options, not open-ended prompts.
|
||||
- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Recommended Actions.
|
||||
- Skipping is allowed only when the report listed **fewer than 3 Priority Issues**. Count them; do not judge the findings "straightforward" by feel. At 3 or more, the questions are required.
|
||||
|
||||
Codex final-question gate: The user-visible response must either include the targeted questions or explicitly say `Questions skipped: <reason>` because the findings were straightforward. Each question must include 2-3 concrete answer options tied to the actual critique findings. Do not end with only open-ended questions.
|
||||
**Final-question gate.** The user-visible response must either include the targeted questions or carry the literal line `Questions skipped: <reason>` naming the count that permitted the skip. Each question must include 2-3 concrete answer options tied to the actual critique findings. Do not end with only open-ended questions, and do not end with neither: stopping after the report, having asked nothing and printed no skip line, is the most common way this command fails.
|
||||
|
||||
### Recommended Actions
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh
|
||||
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Analyze what makes the design feel complex or cluttered:
|
||||
- What can be removed, hidden, or combined?
|
||||
- What's the 20% that delivers 80% of value?
|
||||
|
||||
If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
|
||||
If any of these are unclear from the codebase, do not guess. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
|
||||
|
||||
**CRITICAL**: Simplicity is not about removing features. It's about removing obstacles between users and their goals. Every element should justify its existence.
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ The same restraint applies to `workspace-context-inherited`. Inheritance is a de
|
||||
|
||||
- `workspace-platform-native-evidence` is the finding that matters most here: a workspace carrying native build files while inheriting a root record that resolves to web gets web guidance for its whole life and never loads [ios.md](ios.md) or [android.md](android.md). The repair is a child PRODUCT.md in that workspace, because one inherited record cannot hold two platforms.
|
||||
- `config-project-roots-match-nothing` means every `projectRoots` glob missed, so the repo root is silently standing in as the active project. A renamed workspace directory is the usual cause. Report the patterns and ask which directories they should name.
|
||||
- `config-invalid-build-path` and `config-build-path-unset` both concern one key, `buildPath` in `.impeccable/config.json` (or the gitignored `.impeccable/config.local.json`, which wins for that developer). It holds `comp` or `code` and sets whether new surfaces are built from a generated comp or straight in code. An unread value does not fall back to the opposite path, so a project meaning `code` has been building comp-led; report the exact value. The unset finding fires only where a project has done direction work and never recorded a preference, and the offer belongs in it only when image generation exists in your tool surface. Without image generation there is nothing to choose and nothing to say.
|
||||
- Use the `workspaces` table to show the user which apps carry their own context, which inherit, and which have none, before proposing any change.
|
||||
|
||||
## Opting out of the boot check
|
||||
|
||||
@@ -68,7 +68,7 @@ Omit irrelevant sections rather than filling them with invented rules. Put respo
|
||||
- An existing `DESIGN.md` is stale (the design has drifted).
|
||||
- Before a large redesign, to capture the current state as a reference.
|
||||
|
||||
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file and STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. whether to refresh, overwrite, or merge.
|
||||
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file first. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. The choice is refresh, overwrite, or merge.
|
||||
|
||||
## Two paths
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Identify reusable patterns, components, and design tokens, then extract and cons
|
||||
|
||||
Find the design system, component library, or shared UI directory. Understand its structure: component organization, naming conventions, design token structure, import/export conventions.
|
||||
|
||||
**CRITICAL**: If no design system exists, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. before creating one. Understand the preferred location and structure first.
|
||||
**CRITICAL**: If no design system exists, do not create one yet. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Understand the preferred location and structure first.
|
||||
|
||||
## Step 2: Identify Patterns
|
||||
|
||||
|
||||
@@ -107,9 +107,11 @@ When the platform you just recorded is `ios`, `android`, or `adaptive`, load [io
|
||||
|
||||
Before loading new-work or resuming shape/build, verify that PRODUCT.md exists at the resolved path and contains the confirmed product record. If the file is absent, init is incomplete. Do not substitute interview notes, a planning packet, or later design prose for the file.
|
||||
|
||||
## Step 5: Configure live mode when useful
|
||||
## Step 5: Record workflow defaults
|
||||
|
||||
Skip native or non-runnable projects and leave existing config untouched. Otherwise follow [live.md](live.md)'s first-time setup. Any CSP source edit still requires its stated consent.
|
||||
When image generation is available (context.mjs reports it) and no `buildPath` is recorded yet, ask once how new surfaces should be built, stated as the trade it is: **comp-first** (an image sets the bar before any code; bolder composition, slower, and the build must match the image) or **code-first** (build directly; the ambition is written into the direction contract and audited at the finish; leaner, faster). Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, merging with the keys already there. A value already recorded in `.impeccable/config.json` or the gitignored `.impeccable/config.local.json` is a confirmed answer: on a re-run, honor it in silence rather than asking again. This is a default, not a lock: the decision page renders a toggle whose flip binds a single session and is never written back. Without image generation there is no choice to record; code-first is the only path.
|
||||
|
||||
Then configure live mode when useful: skip native or non-runnable projects and leave existing config untouched. Otherwise follow [live.md](live.md)'s first-time setup. Any CSP source edit still requires its stated consent.
|
||||
|
||||
## Step 6: Wrap up or resume
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Keep the visual system fixed. Derive five to seven materially different structur
|
||||
|
||||
`node .agents/skills/impeccable/scripts/concept-seed.mjs --scope surface --mode <mode>`
|
||||
|
||||
The script assigns which structure gets built; your top-ranked structure is what every run would ship, so the dice come from outside. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
||||
The script deals three of your structures to the table; the dice decide which three reach the user, so the ranking rut stays broken while the user still holds a real choice. Present the three dealt structures on the decision page as full cards of equal salience, the dealt lead carrying kicker THE ROLL, with steer and re-roll; the user locks one in. No canon card and no pick card at surface scope: the world is settled, so every card visualizes composition, not identity. With image generation available and a comp-led default (the build-path paragraph below: `.impeccable/config.json`, the toggle handles the exception), each card declares a `comp` under `.impeccable/mocks/decision/`, generated after serving in reading order under the comp discipline in [visualize.md](visualize.md); anchor each of these comps on the established identity by passing a captured screenshot of a representative existing page as a reference image (the harness image tool's input image, or `generate-image.mjs --ref`) beside a prompt that leads with the new surface's structure and names DESIGN.md's palette, type, and component character, because a prose paraphrase of a design system drifts where a pixel reference does not. Without image generation, or under a code-led default, each card instead carries a `wireframe` layout schematic (see `serve-question.mjs --schema`) that the page draws itself. Locking a card is the approval and sets the build path: a locked comp builds comp-led with that comp as the approved comp, discharging [visualize.md](visualize.md)'s three-option round with no second approval point; a locked wireframe builds code-led, its ambition carried by the direction contract. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
||||
|
||||
### Create or replace the visual world
|
||||
|
||||
@@ -44,13 +44,13 @@ 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, not only its physical objects: the notation, publications, identity programs, data graphics, and interfaces it reads daily; a nameable abstract system (a school of poster, a documentation standard) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; dig until the list spans at least three families.
|
||||
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
|
||||
4. Run `node .agents/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point. The weighing closes with a verdict per challenger, decided before any borrowing is considered: wins (beats the assigned direction on both axes; it becomes the build candidate), competitive (holds one axis; it stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a motif lifted from a declined world is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker MY PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
|
||||
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `followup: true` when the execution-contract round will follow (it does whenever image generation exists and no standing build-path preference is recorded); a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (the build-path paragraph below owns the details); a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. The fallback to the structured question tool is never yours to predict: run the script, and only exit code 2 from starting it routes the decision there; treat that exit as the fallback, never as an error to retry.
|
||||
|
||||
When image generation exists, every card also declares a `sketch` path under `.impeccable/mocks/decision/` (the field keeps its wire name for compatibility; what it carries is the card's comp), the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished sketch pays sketch quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way; visualize.md's self-checks bind decision comps identically. Generation takes the same time at any fidelity, so an unfinished draft pays draft quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
|
||||
The moment the direction lands, one more round on the same open table decides the execution contract. The direction payload declares `followup: true`, so the table stays open after the pick; deliver the build-path payload through `--update` immediately. Two text-only cards. **Comp-led**: a first-viewport comp is generated and it is law, the finish review audits the build against it; boldest composition on the table, fix rounds expected, motion at risk; choosing it makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. Lead with the chosen world's fit: a costume-heavy catalog world leads comp-led, a quiet or conventional direction leads code-led; the lead is a default, never a decision, and the user flips it freely. A standing preference, voiced once, is recorded as a brand commitment in PRODUCT.md and skips this round on later surfaces. Without image generation there is no fork and no round: code-led is the only path, stated in one line rather than asked. Only a detached table (`--start`) stays open for `--update`: a blocking serve or the structured-tool channel runs the build-path round as its own second question instead, and `followup: true` belongs only on a detached round.
|
||||
The execution contract, comp-led or code-led, is a workflow preference, not a per-surface decision, so no round asks it: the recorded default rides every round and the page's toggle handles the exception. Read the default from `.impeccable/config.json` (`buildPath`), with the gitignored `.impeccable/config.local.json` winning where one machine differs from the team's committed value; with neither, comp-led is the default whenever image generation exists. Author every direction and surface payload with `buildPath: { "value": <default>, "toggle": true }`; the page renders a footer toggle with the trade stated beside it, and the ANSWER returns `buildPath` plus `buildPathFlipped`. A flipped value binds that session only and is never written back, with one exception, and it is the only thing inside a round that earns a question about this preference (init records it up front on projects that get the chance): when `buildPathFlipped` comes back true on a project that records no `buildPath` at all, ask once after the round closes whether to keep it as the standing default. Either answer ends in a write to `.impeccable/config.json`; the answer picks the value, never whether to record one. Yes writes the flipped value, and "no, just this once" writes the value they flipped away from, which is the standing default they just confirmed by declining. Ask on the flip and never on the untouched default, because a user who left the toggle alone has told you nothing. A declined offer nothing writes down is an offer the next session makes again. When the user asks in words to change the standing default, update the file without asking. **Comp-led**: the chosen card's comp is law, generated before building when it does not exist yet, and the finish review audits the build against it; boldest composition on the table, fix rounds expected; comp-led makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. A code-led round still declares each card's comp path as a flip reserve: when the user flips the toggle to comp mid-round, `--wait` returns once with BUILD PATH FLIPPED while the page shimmers the slots; generate each open card's comp into its declared path then, lead first, and wait again. The flip back is free, and a comp that already rendered rides at the finish review as the critique reference. Without image generation there is no toggle and no choice: code-led is the only path, stated in one line rather than asked. The old two-card execution-contract round is retired; `followup: true` remains the general mechanism for delivering any later round over the same table via `--update`.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Push an interface past conventional limits. This isn't just about visual effects
|
||||
This command has the highest potential to misfire. Do NOT jump straight into implementation. You MUST:
|
||||
|
||||
1. **Think through 2-3 different directions**: consider different techniques, levels of ambition, and aesthetic approaches. For each direction, briefly describe what the result would look and feel like.
|
||||
2. **STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.** to present these directions and get the user's pick before writing any code. Explain trade-offs (browser support, performance cost, complexity).
|
||||
2. **Get the user's pick before writing any code.** STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Carry each direction's description and its trade-offs (browser support, performance cost, complexity) inside the option itself, so the user is choosing between things they can read. A structured question blocks the message it rides in until the user answers, so directions written alongside the question stay invisible while the user is being asked to choose between them.
|
||||
3. Only proceed with the direction the user confirms.
|
||||
|
||||
Skipping this step risks building something embarrassing that needs to be thrown away.
|
||||
|
||||
@@ -28,7 +28,7 @@ Analyze what makes the design feel too intense:
|
||||
- What's working? (Don't throw away good ideas)
|
||||
- What's the core message? (Preserve what matters)
|
||||
|
||||
If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
|
||||
If any of these are unclear from the codebase, do not guess. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
|
||||
|
||||
**CRITICAL**: "Quieter" doesn't mean boring or generic. It means refined and easier on the eyes. Think luxury, not laziness.
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# Visualize: Direction Comps & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it. A surface-scope structure round that already put three visualized cards before the user (new-work.md, established world) has discharged this round: the locked card’s comp is the approved comp, so record the approval and continue at After approval; generate nothing new.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate three compositional options
|
||||
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here.
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. On an established world, anchor every comp on the real identity: capture a screenshot of a representative existing page and pass it as a reference image (the harness image tool’s input image, or `generate-image.mjs --ref`); the prompt then leads with the new surface’s structure while the reference carries palette, type, and component character, because DESIGN.md words alone drift where a pixel reference does not. Name what the reference contributes and what it must not: chrome, palette, type, and component character carry over; the reference page’s own content does not, so a banner, hero, or card lifted verbatim from the reference is the reference leaking, not fidelity. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here.
|
||||
|
||||
- A comp is a designed surface, not a picture of the subject. Lead the generation prompt with the surface's own structure, whatever regions this design actually has, named in order with their scale relationships; a page with no navigation states that instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with the world's atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph or scene with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally.
|
||||
- The inverse is also a failure: a surface with none of its subject in it. The subject appears as the content the regions exist to hold; the world dresses the frame and never displaces what the frame exists to show. The deletion usually rides in on the prompt's exclusion list, so exclusions bind invented claims, and a medium ban belongs to the committed imagery stance, never to caution. Before accepting a render, point at the subject: a render that depicts everything about the world and nothing of the subject fails however faithful its atmosphere, so regenerate with the subject's content named region by region.
|
||||
- A comp is judged as the shipped screen: the visitor's job must be readable from the image alone. Name the surface's mode from the render with no caption; a render whose mode cannot be read back is art direction without a surface, so regenerate with the visitor's job as the prompt's spine.
|
||||
- Commitment is depth, not coverage. The world enters through one dominant move plus the material, type, and spacing that support it, and the remaining regions hold still so that move can be read; a region that simply does its job in the world's own grammar carries the direction further than a region performing the concept. The check cuts competition, never content: a quieted region keeps its information and stops performing. Where the direction names a focal moment, a second element competing with it at the same scale means the comp is shouting; where it names none, several regions performing the concept at once is the same shout. Regenerate keeping the strongest move and quieting the rest. Busy is louder, not bolder.
|
||||
- When the user shortlisted multiple concepts, spread the three across them.
|
||||
- When 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.
|
||||
@@ -18,7 +21,7 @@ Treat each comp as a direction test, not a screenshot specification. Core UI tex
|
||||
|
||||
## One approval point
|
||||
|
||||
Show the three together: in the harness when it can display images, otherwise on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero). Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
Show the three together on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero), or in the harness only when it renders images inline; a text-only surface does not count as display. Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
|
||||
@@ -338,6 +338,20 @@ export function renderConceptSeed({
|
||||
};
|
||||
const indexSalt = reroll === 0 ? 'index' : `index:reroll-${reroll}`;
|
||||
const buildIndex = 3 + Math.floor(unit(indexSalt) * (candidateCount - 2)); // 3..candidateCount
|
||||
// Surface scope deals a hand of three grounded structures: one card is not
|
||||
// a choice, and the full ranked list would hand selection back to the
|
||||
// model's taste. The dice pick all three; the primary index leads. The
|
||||
// no-lineup rule stays direction-only, where it was written for worlds.
|
||||
const dealtIndices = [buildIndex];
|
||||
for (let draw = 0; scope === 'surface' && dealtIndices.length < Math.min(3, candidateCount); draw += 1) {
|
||||
const idx = 1 + Math.floor(unit(`${indexSalt}:deal-${draw}`) * candidateCount);
|
||||
if (!dealtIndices.includes(idx)) dealtIndices.push(idx);
|
||||
if (draw > 64) { // hash repeats cannot stall the deal
|
||||
for (let fill = 1; dealtIndices.length < Math.min(3, candidateCount); fill += 1) {
|
||||
if (!dealtIndices.includes(fill)) dealtIndices.push(fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local catalog first (private repo, evals, tests), then the roll API,
|
||||
// then a degraded assignment-only seed. The assigned index is pure local
|
||||
@@ -404,19 +418,31 @@ export function renderConceptSeed({
|
||||
interaction and state, and a substantially different future surface. In an
|
||||
attended run, present the assigned direction fully committed and offer
|
||||
re-roll. You may add ONE card for your top-ranked grounded candidate when
|
||||
it is not the assigned direction, kicker MY PICK, with an honest risk line
|
||||
it is not the assigned direction, kicker IMPECCABLE’S PICK, with an honest risk line
|
||||
naming its familiarity; one pick card, never a ranked lineup, and the pick
|
||||
never takes the lead position. When the assignment IS your top candidate,
|
||||
there is no pick card. Re-roll yourself only
|
||||
on named factual grounds, when the assignment cannot carry the product's
|
||||
truth or task; taste is never grounds.`
|
||||
: `After ordering the task's grounded structural candidates by resonance,
|
||||
build candidate ${buildIndex} of your own grounded list; the assignment never
|
||||
points at a challenger. The assignment is the roll, not a suggestion.
|
||||
In an attended run, present the assigned structure and offer re-roll; never
|
||||
present a ranked lineup to choose from. Re-roll yourself only when the
|
||||
assignment fails audience identification or product clarity on named
|
||||
factual grounds.`;
|
||||
deal candidates ${dealtIndices.join(', ')} of your own grounded list to the
|
||||
table; index ${buildIndex} leads, and the deal never points at a challenger.
|
||||
The deal is the roll, not a suggestion: the dice decide which structures
|
||||
reach the user, so the ranking rut stays broken while the user still gets a
|
||||
real choice, and the full ranked list stays yours. In an attended run,
|
||||
present the three dealt structures as full cards of equal salience, the
|
||||
lead carrying kicker THE ROLL, with steer and re-roll, and let the user
|
||||
lock one in; the world is already settled, so this choice is composition.
|
||||
Visualize every dealt card: with image generation available and a
|
||||
comp-led default (.impeccable/config.json buildPath; the page toggle
|
||||
handles the exception), declare a comp per card and generate after
|
||||
serving, lead first; otherwise author each card's wireframe field (see
|
||||
serve-question --schema) and the page draws the schematic. Carry the
|
||||
recorded default in the payload as buildPath with toggle: true. Locking a card
|
||||
approves its comp: a surface round that put three visualized structures on
|
||||
the table replaces the three-option comp round in visualize.md. Re-roll
|
||||
yourself only when every dealt structure fails audience identification or
|
||||
product clarity on named factual grounds.`;
|
||||
|
||||
const challengerInstruction = scope === 'direction'
|
||||
? `Fuse each challenger before judging it: the challenger supplies the form
|
||||
@@ -490,7 +516,7 @@ assigned index is suspended this round and the user picks; seed key ${key}.
|
||||
`
|
||||
: '';
|
||||
return `${degradedHeader}
|
||||
${degradedRegister}ASSIGNED INDEX: ${buildIndex}
|
||||
${degradedRegister}${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
|
||||
${promotedInstruction}
|
||||
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.
|
||||
@@ -514,8 +540,11 @@ channel: when a browser can open, present the direction on the decision page
|
||||
the no-browser fallback.
|
||||
${authorityInstruction}
|
||||
A user- or brief-pinned decision beats the roll, always.
|
||||
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.
|
||||
${scope === 'direction'
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||
: `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index
|
||||
${buildIndex} leads. Present all three dealt structures; seed key ${key}.`}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -595,7 +624,7 @@ rivals to your habitual layout, and keep only what makes this product clearer.${
|
||||
or IMPECCABLE_NO_TELEMETRY is set.\n`
|
||||
: '';
|
||||
const assignedBlock = register === null
|
||||
? `ASSIGNED INDEX: ${buildIndex}
|
||||
? `${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
|
||||
${promotedInstruction}
|
||||
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.`
|
||||
@@ -621,8 +650,11 @@ craft bar, the finish level and commitment the build is expected to reach,
|
||||
never as a mockup to copy; your surface serves this product, not that render.
|
||||
`;
|
||||
const restated = register === null
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
? (scope === 'direction'
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||
: `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index
|
||||
${buildIndex} leads. Present all three dealt structures; seed key ${key}.`)
|
||||
: `REGISTER (restated for truncated readers): ${register}, user-requested; the
|
||||
assigned index is suspended this round; seed key ${key}.`;
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
|
||||
|
||||
@@ -42,14 +42,15 @@ function latestCritique(cwd) {
|
||||
if (!latest) return null;
|
||||
const get = (key) => latest.meta[key] ?? null;
|
||||
const num = (v) => {
|
||||
if (v == null || (typeof v === 'string' && v.trim() === '')) return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
return {
|
||||
slug: get('slug'),
|
||||
score: num(get('score')),
|
||||
p0: num(get('p0')),
|
||||
p1: num(get('p1')),
|
||||
score: num(get('total_score') ?? get('score')),
|
||||
p0: num(get('p0_count') ?? get('p0')),
|
||||
p1: num(get('p1_count') ?? get('p1')),
|
||||
timestamp: get('timestamp'),
|
||||
file: path.relative(cwd, latest.path),
|
||||
};
|
||||
|
||||
@@ -1013,14 +1013,22 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
// still spelled out as the next command. The offer stays; the command leaves
|
||||
// this turn entirely, because installing over the skill mid-session changes
|
||||
// files the session is reading and only takes effect in the next one anyway.
|
||||
function buildUpdateDirective(localVersion, latestVersion) {
|
||||
return (
|
||||
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
|
||||
`(installed v${localVersion}, latest v${latestVersion}). ` +
|
||||
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
|
||||
`Mention it once, in this form: "A newer Impeccable (v${latestVersion}) is available. ` +
|
||||
`Update now? It runs \`npx impeccable update\`." ` +
|
||||
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
|
||||
`Either way, continue the current task without waiting, and do not raise this again.`
|
||||
`Do not run \`npx impeccable update\` in this turn, whatever the user answers: it rewrites the skill files ` +
|
||||
`this session is reading, and the update only takes effect in the next session, so there is nothing to gain now. ` +
|
||||
`Run it in a later turn, only after the user has asked for it in their own words. ` +
|
||||
`Continue the current task now without waiting, and do not raise this again.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1142,6 +1150,7 @@ async function cli() {
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
appendDetectorFallback(parts, ctx);
|
||||
appendImageGenDirective(parts);
|
||||
appendBuildPathDirective(parts, ctx);
|
||||
appendAutonomyCounterDirective(parts);
|
||||
appendSubagentAuthorizationDirective(parts);
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
@@ -1161,6 +1170,7 @@ async function cli() {
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
appendDetectorFallback(parts, ctx);
|
||||
appendImageGenDirective(parts);
|
||||
appendBuildPathDirective(parts, ctx);
|
||||
appendAutonomyCounterDirective(parts);
|
||||
appendSubagentAuthorizationDirective(parts);
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
@@ -1269,6 +1279,53 @@ function automaticHookMode(ctx) {
|
||||
}
|
||||
|
||||
|
||||
// Build-path preference: a workflow setting (comp-led vs code-led), read here
|
||||
// so every session starts knowing it without a file hunt. It rides the unified
|
||||
// config beside the hook and detector settings, and the gitignored local file
|
||||
// wins, because whether a machine has an image tool is a property of that
|
||||
// machine, not of the team's committed default. Absence stays silent;
|
||||
// new-work's own default applies, and the decision page toggle can flip the
|
||||
// value for a single session.
|
||||
function readBuildPathAt(root) {
|
||||
let value = null;
|
||||
let source = null;
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = readJson(path.join(root, '.impeccable', name));
|
||||
if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') {
|
||||
value = raw.buildPath;
|
||||
source = `.impeccable/${name}`;
|
||||
}
|
||||
}
|
||||
return value ? { value, source } : null;
|
||||
}
|
||||
|
||||
// Roots in precedence order, nearest first: the resolved project decides, and
|
||||
// the repo root is the fallback a monorepo commits once for every app in it.
|
||||
// `checkBuildPathUnset` reads exactly these two, and the pair has to match:
|
||||
// when they disagree the finding goes silent because a value exists while the
|
||||
// directive never names it, which is the one combination nobody can debug.
|
||||
//
|
||||
// The invoking directory is deliberately not in the chain. With `--target`
|
||||
// selecting another workspace, cwd is the caller's app, not the target's, and
|
||||
// letting it rank above the repo root hands one workspace another's workflow.
|
||||
// It stands in only when no project resolved at all.
|
||||
function appendBuildPathDirective(parts, ctx) {
|
||||
const roots = [...new Set(
|
||||
[ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((root) => path.resolve(root)),
|
||||
)];
|
||||
for (const root of roots) {
|
||||
const found = readBuildPathAt(root);
|
||||
if (!found) continue;
|
||||
// "Never written back" is scoped by the fact that this directive exists at
|
||||
// all: it is emitted only where a value is already recorded, which is the
|
||||
// case where a flip really is session-only. Saying so inline because the
|
||||
// bare absolute reads as a rule that overrides new-work's one-time offer,
|
||||
// which is exactly how the same wording misfired in serve-question.
|
||||
parts.push(`BUILD_PATH_DEFAULT: ${found.value} (from ${found.source}). Author direction and surface rounds with this as buildPath.value and toggle: true; a flip on the page binds that session only and is never written back, because a default is already recorded here. New-work's one-time offer to record a flipped value applies only where no default exists, which is why you are not seeing this line on those projects.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Image generation availability: harness-native tools always win, but when the
|
||||
// environment carries an OpenAI key the API fallback works everywhere. The
|
||||
// flag only reports capability, positively: absence stays silent, because a
|
||||
|
||||
@@ -14,6 +14,10 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
// reuse the r/g/b-only channel tolerance.
|
||||
const SHADOW_ALPHA_TOLERANCE = 0.02;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
||||
@@ -474,6 +478,25 @@ function addSidecarRadii(out, sidecar) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sidecar `extensions.shadows` entries ({ name, value, purpose }) carry the
|
||||
// documented shadow vocabulary that Stitch's frontmatter schema can't hold.
|
||||
// Their colors go into a separate allowlist — NOT allowedColorKeys — because a
|
||||
// shadow black is only documented *as a shadow*: feeding it into the general
|
||||
// color allowlist would legalize #000 as a page ground (alpha is dropped from
|
||||
// colorKey), which is the hole issue #547 warns against.
|
||||
function addSidecarShadows(out, sidecar) {
|
||||
const shadows = sidecar?.extensions?.shadows;
|
||||
if (!Array.isArray(shadows)) return;
|
||||
|
||||
for (const entry of shadows) {
|
||||
if (typeof entry?.value !== 'string') continue;
|
||||
for (const match of entry.value.matchAll(CSS_COLOR_RE)) {
|
||||
const parsed = parseDesignColor(match[0]);
|
||||
if (parsed) out.allowedShadowColors.push({ color: parsed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDesignSystem(input = {}) {
|
||||
const frontmatter = input.frontmatter || {};
|
||||
const sidecar = input.sidecar || null;
|
||||
@@ -486,6 +509,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
allowedColorKeys: new Map(),
|
||||
allowedRadii: [],
|
||||
allowedFontSizes: [],
|
||||
allowedShadowColors: [],
|
||||
hasPillRadius: false,
|
||||
};
|
||||
|
||||
@@ -495,6 +519,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
addSidecarColors(out, sidecar);
|
||||
addRoundedScale(out, frontmatter.rounded);
|
||||
addSidecarRadii(out, sidecar);
|
||||
addSidecarShadows(out, sidecar);
|
||||
|
||||
out.hasFonts = out.allowedFonts.size > 0;
|
||||
out.hasColors = out.allowedColorKeys.size > 0;
|
||||
@@ -614,6 +639,20 @@ function isAllowedColorRaw(raw, designSystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A color is a documented shadow color only when both the r/g/b channels AND
|
||||
// the alpha match a sidecar shadow token's color. Alpha has to be compared
|
||||
// here because colorKey()/colorsClose() drop it, and a match on r/g/b alone
|
||||
// would let every black at every alpha through.
|
||||
function isAllowedShadowColorRaw(raw, designSystem) {
|
||||
if (!designSystem?.allowedShadowColors?.length) return false;
|
||||
const parsed = parseDesignColor(String(raw || '').trim().toLowerCase());
|
||||
if (!parsed) return false;
|
||||
return designSystem.allowedShadowColors.some(entry =>
|
||||
colorsClose(parsed, entry.color) &&
|
||||
Math.abs((parsed.a ?? 1) - (entry.color.a ?? 1)) <= SHADOW_ALPHA_TOLERANCE,
|
||||
);
|
||||
}
|
||||
|
||||
function isAllowedRadiusRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasRadii) return true;
|
||||
const text = String(raw || '').trim().toLowerCase();
|
||||
@@ -691,6 +730,40 @@ function isProbablyColorLiteral(line, match) {
|
||||
return styleContext || cssFunctionContext || jsColorKeyContext;
|
||||
}
|
||||
|
||||
// One complete `${...}` template interpolation. Its content may carry paired
|
||||
// quoted strings (function arguments, ternary branches) and one level of
|
||||
// braces (an object-literal argument, itself allowing paired quotes). Deeper
|
||||
// nesting would need a parser, so the regex deliberately fails safe there:
|
||||
// the context check misses and the finding fires — a false positive a waiver
|
||||
// can silence, never a leak.
|
||||
const QUOTED_STRING_SRC = `"[^"]*"|'[^']*'`;
|
||||
const INTERPOLATION_SRC =
|
||||
`\\$\\{(?:${QUOTED_STRING_SRC}|\\{(?:${QUOTED_STRING_SRC}|[^{}"'\`])*\\}|[^{}"'\`])*\\}`;
|
||||
// The two shadow-context tails. Unlike jsColorKeyContext, the JS tail admits
|
||||
// commas: a multi-layer shadow string is comma-separated, and a later
|
||||
// property on the same line is still blocked because it sits past the
|
||||
// string's closing quote. Both tails admit complete interpolations; a bare
|
||||
// `}`, quote, or `;` still ends the context.
|
||||
const SHADOW_CSS_CONTEXT_RE = new RegExp(
|
||||
`(?:^|[{\\s;"'\`(,])(?:box-shadow|text-shadow)\\s*:\\s*(?:${INTERPOLATION_SRC}|[^;{}"'\`])*$`, 'i',
|
||||
);
|
||||
const SHADOW_JS_CONTEXT_RE = new RegExp(
|
||||
`(?:^|[,{]\\s*)(?:boxShadow|textShadow)\\s*[:=]\\s*["'\`]?(?:${INTERPOLATION_SRC}|[^"'\`}])*$`, 'i',
|
||||
);
|
||||
|
||||
// True when the color literal sits inside a box-shadow / text-shadow value —
|
||||
// the only contexts where a documented shadow color is legal. Anchored to the
|
||||
// end of `before` (no ; } { or quote in between) so a shadow property earlier
|
||||
// on the line can't leak the allowance into a later declaration. Kept separate
|
||||
// from isProbablyColorLiteral(), which stays a boolean for its existing call
|
||||
// sites and deliberately discards which property matched.
|
||||
function isShadowPropertyContext(line, match) {
|
||||
const index = match.index ?? -1;
|
||||
if (index < 0) return false;
|
||||
const before = line.slice(0, index);
|
||||
return SHADOW_CSS_CONTEXT_RE.test(before) || SHADOW_JS_CONTEXT_RE.test(before);
|
||||
}
|
||||
|
||||
function isInsideCssAttributeSelector(line, index) {
|
||||
if (index < 0) return false;
|
||||
const before = line.slice(0, index);
|
||||
@@ -824,6 +897,7 @@ function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
if (!isProbablyColorLiteral(line, match)) continue;
|
||||
const raw = cssColorLabel(match[0]);
|
||||
if (isAllowedColorRaw(raw, designSystem)) continue;
|
||||
if (isShadowPropertyContext(line, match) && isAllowedShadowColorRaw(raw, designSystem)) continue;
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-color',
|
||||
filePath,
|
||||
@@ -1038,6 +1112,7 @@ export {
|
||||
loadDesignSystemForCwd,
|
||||
isAllowedFont,
|
||||
isAllowedColorRaw,
|
||||
isAllowedShadowColorRaw,
|
||||
isAllowedRadiusRaw,
|
||||
isAllowedFontSizeRaw,
|
||||
checkSourceDesignSystem,
|
||||
|
||||
@@ -425,25 +425,28 @@ const REGEX_MATCHERS = [
|
||||
},
|
||||
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
|
||||
// --- Layout property transition ---
|
||||
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
|
||||
// JSX inline style objects use comma-delimited quoted values, not semicolons (issue #548).
|
||||
{ id: 'layout-transition', regex: /transition\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi,
|
||||
test: (m) => {
|
||||
const val = m[1].toLowerCase();
|
||||
const val = (m[2] ?? m[3] ?? '').toLowerCase();
|
||||
if (/\ball\b/.test(val)) return false;
|
||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||
},
|
||||
fmt: (m) => {
|
||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
|
||||
const raw = m[2] ?? m[3] ?? '';
|
||||
const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition: ${found ? found.join(', ') : raw.trim()}`;
|
||||
} },
|
||||
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
|
||||
{ id: 'layout-transition', regex: /transition-property\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi,
|
||||
test: (m) => {
|
||||
const val = m[1].toLowerCase();
|
||||
const val = (m[2] ?? m[3] ?? '').toLowerCase();
|
||||
if (/\ball\b/.test(val)) return false;
|
||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||
},
|
||||
fmt: (m) => {
|
||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
|
||||
const raw = m[2] ?? m[3] ?? '';
|
||||
const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition-property: ${found ? found.join(', ') : raw.trim()}`;
|
||||
} },
|
||||
// --- Broken image: src="" or src="#" or src=" " ---
|
||||
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkBuildPathUnset,
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
checkNativePlatformEvidence,
|
||||
@@ -120,6 +121,7 @@ async function collect(cwd, targetOptions) {
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...checkHookInstallation({
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
*
|
||||
* node generate-image.mjs --prompt "..." --out mock.png [--size 1536x1024] [--quality medium]
|
||||
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
|
||||
* node generate-image.mjs --prompt "..." --out mock.png --ref screenshot.png [--ref more.png]
|
||||
*
|
||||
* --ref anchors generation on input image(s) via the edits endpoint: pass a
|
||||
* captured screenshot of a representative existing page when comping a new
|
||||
* surface for an established world, so the identity comes from the real UI.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import zlib from 'node:zlib';
|
||||
@@ -212,12 +217,44 @@ if (!prompt || !out) {
|
||||
}
|
||||
const size = arg('size', '1536x1024');
|
||||
const quality = arg('quality', 'medium');
|
||||
// Reference images (--ref, repeatable): route through the edits endpoint,
|
||||
// which accepts input images. This is how a comp for an established world
|
||||
// inherits the real UI's identity from a captured screenshot instead of a
|
||||
// prose paraphrase of it; the prompt then describes the NEW surface and the
|
||||
// reference carries palette, type, and component character.
|
||||
const refs = (() => {
|
||||
const found = [];
|
||||
for (let i = 0; i < process.argv.length; i += 1) {
|
||||
if (process.argv[i] === '--ref' && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) found.push(process.argv[i + 1]);
|
||||
}
|
||||
return found;
|
||||
})();
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/images/generations', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
|
||||
});
|
||||
let response;
|
||||
if (refs.length) {
|
||||
const form = new FormData();
|
||||
form.append('model', 'gpt-image-2');
|
||||
form.append('prompt', prompt);
|
||||
form.append('size', size);
|
||||
form.append('quality', quality);
|
||||
form.append('n', '1');
|
||||
for (const ref of refs) {
|
||||
const bytes = fs.readFileSync(ref);
|
||||
const type = ref.endsWith('.png') ? 'image/png' : ref.endsWith('.webp') ? 'image/webp' : 'image/jpeg';
|
||||
form.append('image[]', new Blob([bytes], { type }), ref.split('/').pop());
|
||||
}
|
||||
response = await fetch('https://api.openai.com/v1/images/edits', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
body: form,
|
||||
});
|
||||
} else {
|
||||
response = await fetch('https://api.openai.com/v1/images/generations', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error(`generate-image: API error ${response.status}: ${(await response.text()).slice(0, 300)}`);
|
||||
process.exit(1);
|
||||
@@ -235,6 +272,6 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
|
||||
try {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2' }, null, 2));
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
|
||||
} catch { /* embedding is best-effort */ }
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
||||
|
||||
@@ -47,18 +47,33 @@ import {
|
||||
|
||||
// Top-level keys any reader honors: `hook` and `detector` subtrees (hook-lib's
|
||||
// readConfig), `updateCheck` (context.mjs), `projectRoots` (context.mjs's
|
||||
// monorepo resolution), plus `stalenessCheck` below. `$schema` and `version`
|
||||
// are allowed as conventional metadata nobody reads.
|
||||
// monorepo resolution), `buildPath` (context.mjs's build-path directive), plus
|
||||
// `stalenessCheck` below. `$schema` and `version` are allowed as conventional
|
||||
// metadata nobody reads.
|
||||
const KNOWN_CONFIG_KEYS = new Set([
|
||||
'hook',
|
||||
'detector',
|
||||
'updateCheck',
|
||||
'stalenessCheck',
|
||||
'projectRoots',
|
||||
'buildPath',
|
||||
'$schema',
|
||||
'version',
|
||||
]);
|
||||
|
||||
// The only two values context.mjs and new-work honor. A near miss reads as a
|
||||
// working preference and silently rides the opposite path, so it is worth
|
||||
// reporting rather than coercing.
|
||||
const BUILD_PATH_VALUES = Object.freeze(['comp', 'code']);
|
||||
|
||||
// Evidence that this project does the kind of work `buildPath` governs. A
|
||||
// project that only ever ran polish or audit has no use for the setting and
|
||||
// should never be told it exists. Two stats, so Tier 1 can afford it.
|
||||
const DIRECTION_WORK_PATHS = Object.freeze([
|
||||
path.join('.impeccable', 'surfaces'),
|
||||
path.join('.impeccable', 'mocks', 'decision'),
|
||||
]);
|
||||
|
||||
// `detector` is a closed set, so a typo here is worth reporting. `hook` is not
|
||||
// checked: it carries runtime settings from several writers and the false
|
||||
// positive rate would outweigh the catch.
|
||||
@@ -325,6 +340,20 @@ export function checkConfig({ projectRoot, repoRoot }) {
|
||||
}));
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'buildPath')
|
||||
&& !BUILD_PATH_VALUES.includes(raw.buildPath)) {
|
||||
findings.push(finding({
|
||||
id: 'config-invalid-build-path',
|
||||
artifact: 'config.json',
|
||||
filePath: rel,
|
||||
severity: 'mention',
|
||||
summary: `${rel} sets \`buildPath\` to ${JSON.stringify(raw.buildPath)}, which nothing reads. `
|
||||
+ `The values are ${BUILD_PATH_VALUES.map((value) => `\`${value}\``).join(' and ')}.`,
|
||||
fix: 'Report the value. An unread `buildPath` does not fall back to the other path; '
|
||||
+ 'it falls back to the default, so a project meaning `code` has been building comp-led.',
|
||||
}));
|
||||
}
|
||||
|
||||
const detector = raw.detector;
|
||||
if (detector && typeof detector === 'object' && !Array.isArray(detector)) {
|
||||
const unknownDetector = Object.keys(detector).filter((key) => !KNOWN_DETECTOR_KEYS.has(key));
|
||||
@@ -345,6 +374,47 @@ export function checkConfig({ projectRoot, repoRoot }) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* No recorded build-path preference on a project that plainly does visual
|
||||
* direction work. Not drift in the usual sense: the setting is newer than the
|
||||
* project, so every project that predates it lands here at once. That is why
|
||||
* it is gated twice, on a product record and on evidence of the work the
|
||||
* setting governs, and why it says the choice rather than assuming a harness
|
||||
* can make it. Image generation is the real precondition and this module
|
||||
* cannot see it: a harness-native image tool leaves no trace on disk, so the
|
||||
* finding hands the question to the one reader that knows.
|
||||
*/
|
||||
export function checkBuildPathUnset({ projectRoot, repoRoot, product }) {
|
||||
if (!projectRoot || !product) return [];
|
||||
const roots = [...new Set([projectRoot, repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
||||
|
||||
for (const root of roots) {
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = readJson(path.join(root, '.impeccable', name));
|
||||
// Any declared value ends this, valid or not: an invalid one already has
|
||||
// its own finding and two reports of one key is noise.
|
||||
if (raw && Object.prototype.hasOwnProperty.call(raw, 'buildPath')) return [];
|
||||
}
|
||||
}
|
||||
|
||||
const evidence = DIRECTION_WORK_PATHS.filter((rel) => fs.existsSync(path.join(projectRoot, rel)));
|
||||
if (!evidence.length) return [];
|
||||
|
||||
return [finding({
|
||||
id: 'config-build-path-unset',
|
||||
artifact: 'config.json',
|
||||
filePath: '.impeccable/config.json',
|
||||
severity: 'mention',
|
||||
summary: 'This project has run visual direction work but records no `buildPath`, '
|
||||
+ 'so every direction round takes the comp-first default without anyone having chosen it.',
|
||||
fix: 'Only when image generation exists in your tool surface, offer the choice once: '
|
||||
+ '**comp-first** (an image sets the bar before any code; bolder composition, slower) or '
|
||||
+ '**code-first** (build directly; ambition carried by the direction contract; leaner, faster). '
|
||||
+ 'Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, '
|
||||
+ 'merging with the keys already there. Without image generation there is no choice to record: stay silent.',
|
||||
})];
|
||||
}
|
||||
|
||||
// ─── Surface briefs ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -446,6 +516,7 @@ export function collectBootFindings(ctx, extras = {}) {
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
|
||||
@@ -170,51 +170,35 @@ Output (JSON):
|
||||
}
|
||||
|
||||
if (svelteComponentManifest) {
|
||||
if (isDiscard) {
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
||||
'discard:' + id,
|
||||
() => {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
return { handled: true };
|
||||
},
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err);
|
||||
}
|
||||
emitResult({
|
||||
...result,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
carbonize: false,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
||||
'accept:' + id,
|
||||
() => inlineSvelteComponentAccept(
|
||||
const { sourceFile, componentDir } = svelteComponentManifest;
|
||||
const resultContext = {
|
||||
file: sourceFile,
|
||||
...(isDiscard ? { carbonize: false } : { sourceFile }),
|
||||
previewMode: 'svelte-component',
|
||||
componentDir,
|
||||
};
|
||||
const runOperation = isDiscard
|
||||
? () => {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
return { handled: true, ...resultContext };
|
||||
}
|
||||
: () => inlineSvelteComponentAccept(
|
||||
svelteComponentManifest,
|
||||
variantNum,
|
||||
paramValues,
|
||||
process.cwd(),
|
||||
),
|
||||
);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), sourceFile),
|
||||
requestedOperation + ':' + id,
|
||||
runOperation,
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err, {
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
sourceFile: svelteComponentManifest.sourceFile,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
});
|
||||
result = operationFailure(err, resultContext);
|
||||
}
|
||||
if (result.carbonize) {
|
||||
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
|
||||
|
||||
@@ -22,6 +22,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
// All known harness directories
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
|
||||
'.hermes',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
||||
];
|
||||
|
||||
|
||||
@@ -42,14 +42,14 @@
|
||||
* // raise lines under the identity row
|
||||
* "risk": "one line: the honest risk", // optional
|
||||
* "body": "fallback prose when the structured fields are absent",
|
||||
* "sketch": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
|
||||
* // full-fidelity direction comp (the field
|
||||
* // keeps the sketch era's wire name). May not
|
||||
* // exist yet: the page shimmer-waits and
|
||||
* "comp": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
|
||||
* // full-fidelity direction comp (the legacy
|
||||
* // key "sketch" is accepted as an alias). May
|
||||
* // not exist yet: the page shimmer-waits and
|
||||
* // polls the slot until the file lands, so
|
||||
* // serve first and generate after
|
||||
* "hero": "https://... or /abs/path.webp", // optional inspiration image;
|
||||
* // rides picture-in-picture when a sketch exists
|
||||
* // rides picture-in-picture when a comp exists
|
||||
* "board": "https://... or /abs/path.webp" // optional secondary image
|
||||
* }, ...
|
||||
* ],
|
||||
@@ -61,7 +61,7 @@
|
||||
* "canon": true, // adds the "Play it straight" standing exit;
|
||||
* // direction rounds only (returns {"optionId":"canon"})
|
||||
* "canonCard": { ... }, // optional: the standing exit as a full card with the
|
||||
* // same anatomy (label, thesis, palette, sketch, ...);
|
||||
* // same anatomy (label, thesis, palette, comp, ...);
|
||||
* // rendered last and visually subordinate. Without it,
|
||||
* // canon stays a quiet footer action.
|
||||
* "steer": true, // adds a free-text steer field returned with any answer
|
||||
@@ -75,7 +75,7 @@
|
||||
* // then the execution contract.
|
||||
* }
|
||||
*
|
||||
* Options render as large cards: the sketch leads when present, with the
|
||||
* Options render as large cards: the comp leads when present, with the
|
||||
* inspiration image picture-in-picture; a hero alone renders full-bleed; a
|
||||
* text-only direction gets its identity from the palette chips and tags.
|
||||
* Local image paths are served by this server; nothing is uploaded anywhere.
|
||||
@@ -148,7 +148,7 @@ function printAnswer(raw) {
|
||||
if (a.hero || a.board) {
|
||||
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code. When your harness only reads files, or runs sandboxed, download them INTO the workspace and open the relative path; a sandboxed viewer rejects absolute paths outside it. They set the craft bar the build must reach.");
|
||||
}
|
||||
if (a.sketch) {
|
||||
if (a.comp) {
|
||||
console.log('CHOSEN COMP: the decision comp at that path is compositional option one. On a comp-led build the comp round adds two variations beside it; on a code-led build it returns at the finish review as the critique reference. Never regenerate it from scratch.');
|
||||
}
|
||||
if (a.optionId === 'canon') {
|
||||
@@ -160,6 +160,18 @@ function printAnswer(raw) {
|
||||
if (a.followup && a.optionId !== 'reroll') {
|
||||
console.log('FOLLOWUP OPEN: the table stays open and the page is showing a loading hand. Deliver the next round now with --update --key <key> --payload <file>, then collect it with --wait; never leave the page waiting on a round you have not sent.');
|
||||
}
|
||||
if (a.buildPath === 'comp' || a.buildPath === 'code') {
|
||||
// The page never writes the flip itself, but "never write it" overstated
|
||||
// that into a rule the agent then applied to new-work's one-time offer,
|
||||
// which exists for exactly this case: a flip on a project that had no
|
||||
// recorded default is the only moment the preference is ever asked for.
|
||||
const origin = a.buildPathFlipped
|
||||
? 'flipped on the page, so it binds this session only, and the page never writes it back; the sole exception is new-work’s one-time offer, on a project that had no recorded default at all, which asks after the round closes and writes the answer to .impeccable/config.json'
|
||||
: 'the round’s recorded default';
|
||||
console.log(`BUILD PATH: ${a.buildPath} (${origin}). ${a.buildPath === 'comp'
|
||||
? 'Comp-led: the chosen card’s comp is law; generate it before building when it does not exist yet, and the finish review audits the build against it.'
|
||||
: 'Code-led: no comp is owed; a comp that already rendered rides at the finish review as the critique reference, and the ambition lives in the direction contract.'}`);
|
||||
}
|
||||
} catch { /* raw answer */ }
|
||||
}
|
||||
|
||||
@@ -169,23 +181,29 @@ const portArg = Number(arg('port', '0'));
|
||||
const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions');
|
||||
const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`);
|
||||
const answerFile = (key) => path.join(QUESTION_DIR, `${key}.answer.json`);
|
||||
// A code-to-comp flip mid-round: the page records it here and --wait
|
||||
// surfaces it as its own event, because the agent must start generating
|
||||
// comps while the round is still open. Comp-to-code needs no event; it is
|
||||
// free and rides the final ANSWER.
|
||||
const flipFile = (key) => path.join(QUESTION_DIR, `${key}.flip.json`);
|
||||
|
||||
if (hasFlag('schema')) {
|
||||
console.log(JSON.stringify({
|
||||
title: 'Choose the visual world',
|
||||
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
|
||||
options: [
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], sketch: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
|
||||
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'MY PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as today’s ballad.', risk: 'Also the direction most runs in this category land on.', sketch: '.impeccable/mocks/decision/model-pick.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', sketch: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], comp: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
|
||||
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'IMPECCABLE’S PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as today’s ballad.', risk: 'Also the direction most runs in this category land on.', comp: '.impeccable/mocks/decision/model-pick.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', comp: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'challenger-microfiche', label: 'Microfiche Reader', verdict: 'declined', lineage: 'library microfiche stations', palette: ['#101418', '#9fb4c0'], materials: ['film grain', 'backlit glass'], case: 'Fuses poorly: listeners do not identify with archival retrieval.', kept: 'Total environmental commitment.', hero: 'https://impeccable.style/worlds/cards/archives-microfiche-reader-hero.webp' },
|
||||
],
|
||||
reroll: { registers: ['safer', 'bolder'] },
|
||||
buildPath: { value: 'comp', toggle: true },
|
||||
canon: true,
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', sketch: '.impeccable/mocks/decision/canon.webp' },
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', comp: '.impeccable/mocks/decision/canon.webp' },
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no sketch (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "MY PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. The sketch slot carries the card’s full-fidelity direction comp (the field keeps its wire name for compatibility). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. followup: true keeps the table open after a pick for a second round via --update (direction first, then the execution contract); send the next payload immediately, the page is waiting on it.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/comp accept URLs or local paths; comp slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the comp, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no comp (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "IMPECCABLE’S PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. A card may instead declare "wireframe" ({"cols":12,"rows":10,"regions":[{"label":"nav rail","x":0,"y":0,"w":3,"h":10,"accent":true}]}): the page draws it as a layout schematic in the media slot; surface-scope rounds use it on code-led builds, it never counts toward salience, and the card keeps its full read on the front. The comp slot carries the card’s full-fidelity direction comp (the legacy key "sketch" is accepted as an alias). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. buildPath rides the payload as { "value": "comp"|"code", "toggle": true }: the value is the recorded default (.impeccable/config.json buildPath, or .impeccable/config.local.json where one machine differs) and the toggle renders a footer switch whose flip binds that session only; the ANSWER then carries buildPath plus buildPathFlipped. On a code-led round each card still declares its comp path as a flip reserve: wireframes render, and a flip to comp makes --wait return once with BUILD PATH FLIPPED so you generate the comps into the declared slots while the round stays open; a flip back to code is free, and a comp that already landed stays as the critique reference. The toggle may only be offered when image generation exists: a harness with no image tool and no API key never sets toggle: true, so the choice never renders where comps cannot be made, and code-led simply rides as the untoggleable value. followup: true keeps the table open after a pick for a second round via --update; send the next payload immediately, the page is waiting on it.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -212,6 +230,13 @@ if (hasFlag('wait')) {
|
||||
let sawClose = false;
|
||||
while (Date.now() < deadline) {
|
||||
if (answered()) break;
|
||||
// A build-path flip is its own event, not an answer: the round stays
|
||||
// open, and the agent's job right now is comps, not code.
|
||||
if (fs.existsSync(flipFile(key))) {
|
||||
try { fs.rmSync(flipFile(key)); } catch { /* consumed elsewhere */ }
|
||||
console.log('BUILD PATH FLIPPED: comp (for this session only; never write it to settings). The table is still open and the page shows shimmer where the images will land: generate each open card’s comp into its declared path now, lead first, then collect the answer with --wait again. A card whose comp already exists needs nothing.');
|
||||
process.exit(0);
|
||||
}
|
||||
if (!alive()) {
|
||||
console.log('serve-question: the question server is gone with no answer. This is a server failure, not a user decision: restart it with --start and the same payload, reopen the URL for the user, and wait again. Never proceed without their choice while their browser session is open.');
|
||||
process.exit(2);
|
||||
@@ -307,6 +332,12 @@ else raw = fs.readFileSync(0, 'utf8');
|
||||
let payload;
|
||||
let options;
|
||||
let localImages = [];
|
||||
// Build path (comp-led vs code-led): the payload carries the recorded
|
||||
// default; the page's toggle updates the live value per session. The server
|
||||
// owns both so the final ANSWER states the path and whether it was flipped
|
||||
// even when the round never rendered a toggle.
|
||||
let buildPathDefault = null;
|
||||
let liveBuildPath = null;
|
||||
|
||||
function loadRound(json) {
|
||||
const parsed = JSON.parse(json);
|
||||
@@ -322,10 +353,10 @@ function loadRound(json) {
|
||||
localImages.push(abs);
|
||||
return `/img/${localImages.length - 1}`;
|
||||
};
|
||||
// Sketches stream in after the page is served, so their slots register
|
||||
// Comps stream in after the page is served, so their slots register
|
||||
// whether or not the file exists yet; /img answers 404 until it lands and
|
||||
// the page polls the slot. Remote sketch URLs pass through untouched.
|
||||
const sketchSrc = (value) => {
|
||||
// the page polls the slot. Remote comp URLs pass through untouched.
|
||||
const compSrc = (value) => {
|
||||
if (!value) return null;
|
||||
if (/^https?:\/\//.test(value)) return value;
|
||||
localImages.push(path.resolve(value));
|
||||
@@ -336,7 +367,7 @@ function loadRound(json) {
|
||||
...option,
|
||||
heroSrc: imageSrc(option.hero),
|
||||
boardSrc: imageSrc(option.board),
|
||||
sketchSrc: sketchSrc(option.sketch),
|
||||
compSrc: compSrc(option.comp ?? option.sketch),
|
||||
});
|
||||
options = parsed.options.map(decorate);
|
||||
// The verdict routes rendering: full cards first, then the canon, then the
|
||||
@@ -352,6 +383,10 @@ function loadRound(json) {
|
||||
options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
|
||||
}
|
||||
options = [...options, ...declined];
|
||||
buildPathDefault = (parsed.buildPath && (parsed.buildPath.value === 'comp' || parsed.buildPath.value === 'code'))
|
||||
? { value: parsed.buildPath.value, toggle: parsed.buildPath.toggle === true }
|
||||
: null;
|
||||
liveBuildPath = buildPathDefault?.value ?? null;
|
||||
}
|
||||
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
|
||||
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
|
||||
@@ -368,18 +403,25 @@ function page() {
|
||||
// no generation luck can distort.
|
||||
const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
|
||||
const demoted = (option) => option.verdict === 'declined';
|
||||
// The build path (comp-led vs code-led) is a workflow preference, not a
|
||||
// design decision: the payload carries the recorded default and whether
|
||||
// the page offers the toggle. On a code-led round a declared comp path is
|
||||
// a flip reserve, not a face: wireframes render, and the slot only starts
|
||||
// shimmering when the user flips to comp.
|
||||
const buildPath = buildPathDefault;
|
||||
const codeLed = buildPath?.value === 'code';
|
||||
// Salience parity: a card's imagery weight is capped by the assigned card's.
|
||||
// When the lead card has no media at all (no image generation this round,
|
||||
// and no catalog art of its own), full-bleed catalog art beside a text-only
|
||||
// assigned card would let rendering luck outvote the weighing: users click
|
||||
// the colorful thing. Declined cards are thumb-only regardless; the verdict
|
||||
// demoted them, and a full-bleed hero would promote them right back.
|
||||
const identityRound = !(options[0] && (options[0].sketchSrc || options[0].heroSrc || options[0].boardSrc));
|
||||
// A declined card never renders a full media face, sketch included: even a
|
||||
// declared sketch would buy back the salience the verdict took away.
|
||||
const faceSketch = (option) => demoted(option) ? null : option.sketchSrc;
|
||||
const thumbOnly = (option) => !faceSketch(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
|
||||
const hasMedia = (option) => Boolean(faceSketch(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
|
||||
const identityRound = !(options[0] && (options[0].compSrc || options[0].heroSrc || options[0].boardSrc));
|
||||
// A declined card never renders a full media face, comp included: even a
|
||||
// declared comp would buy back the salience the verdict took away.
|
||||
const faceComp = (option) => (demoted(option) || codeLed) ? null : option.compSrc;
|
||||
const thumbOnly = (option) => !faceComp(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
|
||||
const hasMedia = (option) => Boolean(faceComp(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
|
||||
// The back exists to keep long facts off a card whose front is an image;
|
||||
// a card with no art has no flip chip to reach it, so it gets no back and
|
||||
// the full read lives on the front instead.
|
||||
@@ -402,15 +444,16 @@ function page() {
|
||||
// generous hand cannot blow the card out of proportion.
|
||||
if (Array.isArray(option.raised) && option.raised.length) {
|
||||
const nameOf = (id) => options.find((o) => o.id === id)?.label || String(id ?? '');
|
||||
const raiseLines = option.raised.slice(0, 6).map((r) => `<p class="raise"><span class="fact-label">Raised by ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
|
||||
const raiseLines = option.raised.slice(0, 6).map((r) => `<p class="raise"><span class="fact-label">From ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
|
||||
const raisesHead = (count) => `<div class="raises-head"><span class="fact-label">Improved by Impeccable's worlds</span>${count > 1 ? `<span class="raises-count" data-raises-count>1/${count}</span>` : ''}</div>`;
|
||||
if (raiseLines.length > 1) {
|
||||
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter to see the next raise" aria-label="Raised by the hand; activate to see the next raise">
|
||||
<div class="raises-head"><span class="fact-label">Raised by the hand</span><span class="raises-count" data-raises-count>1/${raiseLines.length}</span></div>
|
||||
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter for the next improvement" aria-label="How Impeccable's worlds improved this direction; activate to see the next improvement">
|
||||
${raisesHead(raiseLines.length)}
|
||||
${raiseLines.join('')}
|
||||
<span class="sr-live" aria-live="polite"></span>
|
||||
</div>`);
|
||||
} else {
|
||||
rows.push(`<div class="raises">${raiseLines[0]}</div>`);
|
||||
rows.push(`<div class="raises">${raisesHead(1)}${raiseLines[0]}</div>`);
|
||||
}
|
||||
}
|
||||
// Demoted art stays reachable as a labeled thumb: the catalog world
|
||||
@@ -421,7 +464,7 @@ function page() {
|
||||
}
|
||||
// The front carries only what the choice needs: thesis, identity, and the
|
||||
// honest risk clamped to two lines. First viewport and the case read on
|
||||
// the card's back; once the sketch lands, the first viewport is a picture.
|
||||
// the card's back; once the comp lands, the first viewport is a picture.
|
||||
// With no art there is no back, so the full read fills the room the
|
||||
// image would have taken.
|
||||
if (hasMedia(option)) {
|
||||
@@ -444,24 +487,27 @@ function page() {
|
||||
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
|
||||
].filter(Boolean).join('\n ');
|
||||
const media = (option) => {
|
||||
const inspiration = option.heroSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(option.heroSrc)}" alt="">
|
||||
const inspirationSrc = option.heroSrc || option.boardSrc;
|
||||
const inspiration = inspirationSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(inspirationSrc)}" alt="">
|
||||
<figcaption>inspiration</figcaption>
|
||||
</figure>` : '';
|
||||
const details = hasBack(option) ? flipChip('Details') : '';
|
||||
// Thumb-only art renders inside the body via anatomy(), never as a face,
|
||||
// and a declined card's sketch slot is ignored outright.
|
||||
// and a declined card's comp slot is ignored outright.
|
||||
if (thumbOnly(option)) return '';
|
||||
if (faceSketch(option)) {
|
||||
return `<div class="media sketching" data-sketch="${esc(option.sketchSrc)}">
|
||||
<div class="shimmer"><span class="sketch-note">rendering…</span></div>
|
||||
<img class="sketch" alt="" hidden>
|
||||
if (faceComp(option)) {
|
||||
const textOnlyFacts = backFacts(option);
|
||||
return `<div class="media comp-pending" data-comp="${esc(option.compSrc)}">
|
||||
<div class="shimmer"><span class="comp-note">rendering…</span></div>
|
||||
<img class="comp" alt="" hidden>
|
||||
${inspiration}
|
||||
<template class="text-only-facts">${textOnlyFacts}</template>
|
||||
<div class="chips">${expandChip}${details}</div>
|
||||
</div>`;
|
||||
}
|
||||
if (option.heroSrc || option.boardSrc) {
|
||||
// Without a sketch the catalog art is the card's face; it stays a
|
||||
// Without a comp the catalog art is the card's face; it stays a
|
||||
// labeled reference so it never reads as the promise of the build.
|
||||
return `<div class="media" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||
@@ -471,13 +517,36 @@ function page() {
|
||||
}
|
||||
return '';
|
||||
};
|
||||
// Wireframe media: a code-led card's layout schematic, authored as grid
|
||||
// regions in the payload and drawn by the page; boxes and labels, no art.
|
||||
// It fills the media slot only when the card has no imagery, and it never
|
||||
// counts toward salience or earns a card back: the full read stays on the
|
||||
// front, exactly like a text-only card.
|
||||
const wire = (option) => {
|
||||
const frame = option.wireframe;
|
||||
if (!frame || !Array.isArray(frame.regions) || !frame.regions.length || media(option) || demoted(option)) return '';
|
||||
const cols = Number(frame.cols) > 0 ? Number(frame.cols) : 12;
|
||||
const rows = Number(frame.rows) > 0 ? Number(frame.rows) : 10;
|
||||
const pct = (n, total) => `${Math.max(0, Math.min(100, (n / total) * 100)).toFixed(2)}%`;
|
||||
const cells = frame.regions.slice(0, 12).map((region) => {
|
||||
const x = Number(region.x) || 0;
|
||||
const y = Number(region.y) || 0;
|
||||
const w = Math.max(Number(region.w) || 1, 0.5);
|
||||
const h = Math.max(Number(region.h) || 1, 0.5);
|
||||
return `<div class="wire-region${region.accent ? ' accent' : ''}" style="left:${pct(x, cols)};top:${pct(y, rows)};width:${pct(w, cols)};height:${pct(h, rows)}"><span>${esc(region.label || '')}</span></div>`;
|
||||
}).join('');
|
||||
return `<div class="media wire" role="img" aria-label="Layout schematic">
|
||||
<div class="wire-field">${cells}</div>
|
||||
<p class="media-label">layout</p>
|
||||
</div>`;
|
||||
};
|
||||
const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this';
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}"${codeLed && option.compSrc && !demoted(option) ? ` data-comp-slot="${esc(option.compSrc)}"` : ''}>
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${(media(option) || wire(option)) ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : demoted(option) ? '<span class="kicker declined-k">Declined</span>' : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
|
||||
${media(option)}
|
||||
${media(option) || wire(option)}
|
||||
<div class="body">
|
||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||
<h2>${esc(option.label)}</h2>
|
||||
@@ -526,9 +595,12 @@ function page() {
|
||||
--ks-font-display: "Alumni Sans", "Albert Sans", Arial, sans-serif;
|
||||
--ks-font: "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, system-ui, sans-serif;
|
||||
--ks-mono: "SFMono-Regular", "Roboto Mono", "JetBrains Mono", Consolas, monospace;
|
||||
/* One inset shared by the content column, the deck's snap padding, and
|
||||
the sticky footer, so all three align on the same 90rem column. */
|
||||
--page-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2));
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 2rem; min-height: 100dvh; display: flex; flex-direction: column; overflow-x: clip; }
|
||||
body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 0; min-height: 100dvh; display: flex; flex-direction: column; overflow-x: clip; }
|
||||
#ambient { position: fixed; inset: -40px; z-index: 0; background-size: cover; background-position: center; filter: blur(34px) saturate(1.05); opacity: 0; transition: opacity .55s ease, background-image .2s; pointer-events: none; }
|
||||
#scrim { position: fixed; inset: 0; z-index: 0; background: linear-gradient(180deg, oklch(7% 0.006 95 / 0.62), oklch(7% 0.006 95 / 0.78)); pointer-events: none; }
|
||||
header, main, footer { position: relative; z-index: 1; }
|
||||
@@ -540,7 +612,7 @@ function page() {
|
||||
.brand { display: flex; align-items: center; gap: .55rem; color: var(--ks-kinpaku); }
|
||||
.brand svg { width: 22px; height: 22px; }
|
||||
.wordmark { font-family: var(--ks-font-display); font-weight: 400; font-size: 1.125rem; letter-spacing: 0.15em; text-transform: uppercase; line-height: 1; color: var(--ks-kinpaku); }
|
||||
.headline { display: flex; align-items: center; gap: .9rem; }
|
||||
.headline { display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; }
|
||||
.headline-die { flex: none; width: 34px; height: 34px; color: var(--ks-kinpaku); }
|
||||
h1 { font-family: var(--ks-font-display); font-weight: 100; font-size: clamp(2.6rem, 5vw, 4.2rem); letter-spacing: -0.01em; line-height: 1.02; color: var(--ks-champagne); }
|
||||
.question { color: var(--ks-text-muted); margin-top: .7rem; max-width: 52rem; }
|
||||
@@ -552,12 +624,24 @@ function page() {
|
||||
.deck-shell { position: relative; width: 100vw; margin-left: calc(50% - 50vw); }
|
||||
/* One row in a wide viewport, one column in a tall one; the deck scrolls on
|
||||
its axis with snap points and the arrows page it card by card. */
|
||||
.grid { --deck-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2)); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; }
|
||||
.grid { --deck-inset: var(--page-inset); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; }
|
||||
.grid::-webkit-scrollbar { display: none; }
|
||||
/* Wide enough that the sketch carries the card: at 27vw the imagery read
|
||||
/* Wide enough that the comp carries the card: at 27vw the imagery read
|
||||
as a thumbnail above a column of copy, and the copy won the attention
|
||||
contest the sketch is supposed to win. */
|
||||
contest the comp is supposed to win. */
|
||||
.grid > .card { flex: 0 0 clamp(24rem, 34vw, 34rem); scroll-snap-align: center; }
|
||||
/* Short landscape viewports (13-inch laptops): header, a 34vw card, and the
|
||||
footer do not fit 800px of height, so the headline compacts and the deck
|
||||
narrows. Height is the axis that gives; the sticky footer keeps the
|
||||
round's verbs on screen while a too-tall card scrolls. */
|
||||
@media (min-aspect-ratio: 1/1) and (max-height: 900px) {
|
||||
body { padding-top: 1.1rem; }
|
||||
h1 { font-size: clamp(2rem, 3.4vw, 2.9rem); }
|
||||
.question { margin-top: .45rem; }
|
||||
.stage { gap: 1rem; }
|
||||
.grid > .card { flex-basis: clamp(20rem, 27vw, 27rem); }
|
||||
.grid > .card.declined { flex-basis: clamp(13rem, 18vw, 18rem); }
|
||||
}
|
||||
.nav { position: absolute; z-index: 6; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: oklch(7% 0.006 95 / 0.78); border: 1px solid var(--ks-rule); color: var(--ks-kinpaku); cursor: pointer; backdrop-filter: blur(6px); transition: border-color .2s, color .2s, opacity .2s; }
|
||||
.nav:hover { border-color: var(--ks-kinpaku-deep); color: var(--ks-kinpaku-pale); }
|
||||
.nav[disabled] { opacity: .25; cursor: default; }
|
||||
@@ -588,6 +672,10 @@ function page() {
|
||||
shrink a declined card to content WIDTH, not height, so it stretches
|
||||
like every other card and its height is already its own. */
|
||||
.grid > .card.declined { align-self: stretch; }
|
||||
/* The sticky bar is a wide-viewport fix. Here it would sit over the
|
||||
deck's More pager and cost a third of a phone screen, and the deck
|
||||
already scrolls internally, so the footer stays in the page flow. */
|
||||
footer { position: static; width: auto; margin: 1rem 0 0; padding: .7rem 0 1.2rem; background: transparent; border-top: 0; backdrop-filter: none; }
|
||||
}
|
||||
.card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
|
||||
.card:hover { transform: rotate(0deg) translateY(-4px); }
|
||||
@@ -611,7 +699,7 @@ function page() {
|
||||
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; }
|
||||
/* 16/10 matches the landscape sketch frame; portrait art overrides the
|
||||
/* 16/10 matches the landscape comp frame; portrait art overrides the
|
||||
slot with its own exact ratio at load (see the load listener), and the
|
||||
deck narrows so portrait cards line up side by side. */
|
||||
.media { position: relative; width: 100%; aspect-ratio: 16/10; flex: none; }
|
||||
@@ -647,14 +735,14 @@ function page() {
|
||||
.body.back-body { overflow-y: auto; flex: 1; scrollbar-width: thin; }
|
||||
/* Inspiration rides picture-in-picture: the catalog world explains where the
|
||||
direction comes from without promising what the build will look like. */
|
||||
/* Hovering the inspiration takes over the whole media region; the sketch is
|
||||
/* Hovering the inspiration takes over the whole media region; the comp is
|
||||
the promise, the inspiration is a glance, so the glance must cost nothing. */
|
||||
.pip { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; width: 84px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; background: var(--ks-lacquer); cursor: zoom-in; transition: left .35s cubic-bezier(.16,1,.3,1), bottom .35s cubic-bezier(.16,1,.3,1), width .35s cubic-bezier(.16,1,.3,1), height .35s cubic-bezier(.16,1,.3,1), border-radius .35s ease; box-shadow: 0 6px 18px oklch(0% 0 0 / 0.45); }
|
||||
.pip img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 3px 0 4px; background: oklch(7% 0.006 95 / 0.72); backdrop-filter: blur(3px); }
|
||||
.pip:hover { left: 0; bottom: 0; width: 100%; height: 100%; border-radius: 0; z-index: 3; }
|
||||
.sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||
/* Catalog art standing in for a sketchless card is a reference, and says so
|
||||
.comp-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||
/* Catalog art standing in for a comp-less card is a reference, and says so
|
||||
on its face; the same pill later carries "artwork unavailable". */
|
||||
.media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); padding: 3px 8px 4px; background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 4px; backdrop-filter: blur(3px); }
|
||||
/* Art that never arrives collapses to the card's own palette (painted
|
||||
@@ -666,15 +754,15 @@ function page() {
|
||||
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
|
||||
.media.unavailable .chips { z-index: 2; }
|
||||
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
||||
the real sketch whenever it lands. */
|
||||
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
||||
the real comp whenever it lands. */
|
||||
.media.stand-in img.comp { filter: brightness(.72) saturate(.85); }
|
||||
.media.stand-in .pip { display: none; }
|
||||
.stand-in-label { position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; margin: 0; font-family: var(--ks-mono); font-size: .56rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 4px 0 5px; background: oklch(7% 0.006 95 / 0.78); backdrop-filter: blur(3px); }
|
||||
.media.sketching { position: relative; }
|
||||
.media.sketching .shimmer { position: absolute; inset: 0; }
|
||||
.media img.sketch { position: relative; z-index: 1; }
|
||||
.media.comp-pending { position: relative; }
|
||||
.media.comp-pending .shimmer { position: absolute; inset: 0; }
|
||||
.media img.comp { position: relative; z-index: 1; }
|
||||
/* The generic .media img display:block would defeat [hidden] and float an
|
||||
empty block over the shimmer; an unloaded sketch must truly not render. */
|
||||
empty block over the shimmer; an unloaded comp must truly not render. */
|
||||
.media img[hidden] { display: none; }
|
||||
/* Declined challengers: the weighing demoted them, so the card is narrower
|
||||
and quieter, its catalog art rides as a labeled thumb in the body, and
|
||||
@@ -690,22 +778,33 @@ function page() {
|
||||
.kicker.declined-k { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); }
|
||||
.card.declined button.choose { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font-size: .85rem; padding: 8px 22px; }
|
||||
.card.declined button.choose:hover { background: var(--ks-graphite-2); border-color: var(--ks-text-muted); }
|
||||
/* Wireframe media: the code-led schematic. Quiet boxes in the card's own
|
||||
chrome; uniform salience across cards by construction, so it needs no
|
||||
parity rules. */
|
||||
.media.wire { background: var(--ks-lacquer); border-bottom: 1px solid var(--ks-rule); }
|
||||
.wire-field { position: absolute; inset: 12px 12px 26px; }
|
||||
.wire-region { position: absolute; border: 1px solid oklch(78% 0 0 / 0.26); border-radius: 3px; background: oklch(78% 0 0 / 0.05); display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
||||
.wire-region span { font-family: var(--ks-mono); font-size: .55rem; letter-spacing: .1em; text-transform: uppercase; color: var(--ks-text-faint); text-align: center; padding: 2px 4px; }
|
||||
.wire-region.accent { border-color: oklch(84% 0.19 80.46 / 0.5); background: oklch(84% 0.19 80.46 / 0.06); }
|
||||
.wire-region.accent span { color: var(--ks-kinpaku-rich); }
|
||||
/* Thumb-scale inspiration: present, labeled, zoomable, and incapable of
|
||||
outshouting a text-only assigned card. */
|
||||
.inspo { position: relative; flex: none; margin: 2px 0; width: 104px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; cursor: zoom-in; background: var(--ks-lacquer); }
|
||||
.inspo img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.inspo figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .48rem; letter-spacing: .16em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 2px 0 3px; background: oklch(7% 0.006 95 / 0.72); }
|
||||
/* Raises: the donations the assigned direction took from the hand it beat,
|
||||
each named for its donor. Patina, not kinpaku: a raise is provenance. */
|
||||
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; }
|
||||
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; border-left: 2px solid var(--ks-patina); padding-left: 8px; }
|
||||
/* Raises: the improvements the dealt worlds donated to the assigned
|
||||
direction, each named for its donor world. Patina, not kinpaku:
|
||||
provenance, not a call to action. A quiet contained panel, never an
|
||||
accent side-tab. */
|
||||
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; padding: 7px 10px 8px; background: oklch(70% 0.12 188 / 0.06); border: 1px solid oklch(70% 0.12 188 / 0.22); border-radius: 8px; }
|
||||
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; }
|
||||
.raise .fact-label { color: var(--ks-patina); }
|
||||
/* Several raises cycle instead of stacking: one visible at a time, a
|
||||
/* Several kept ideas cycle instead of stacking: one visible at a time, a
|
||||
counter for the rest, the whole block advances on click. */
|
||||
.raises-cycle { cursor: pointer; border-radius: 6px; }
|
||||
.raises-cycle .raise { display: none; border-left: none; padding-left: 0; }
|
||||
.raises-cycle { cursor: pointer; transition: border-color .2s ease; }
|
||||
.raises-cycle:hover { border-color: oklch(70% 0.12 188 / 0.45); }
|
||||
.raises-cycle .raise { display: none; }
|
||||
.raises-cycle .raise.active { display: block; }
|
||||
.raises-cycle { border-left: 2px solid var(--ks-patina); padding-left: 8px; }
|
||||
.raises-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.raises-head .fact-label { color: var(--ks-patina); }
|
||||
.raises-count { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .14em; color: var(--ks-text-faint); }
|
||||
@@ -721,9 +820,39 @@ function page() {
|
||||
.card.canon button.choose:hover { border-color: var(--ks-text-muted); background: var(--ks-graphite-2); }
|
||||
button.choose { margin-top: auto; align-self: start; background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font-family: var(--ks-font); font-size: 1rem; font-weight: 500; line-height: 1.35; padding: 10px 38px; border-radius: 6px; cursor: pointer; transition: background .15s; }
|
||||
button.choose:hover { background: var(--ks-kinpaku-pale); }
|
||||
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
||||
/* The round's verbs stay reachable on short viewports: the footer is a
|
||||
full-bleed bar stuck to the viewport bottom and the deck scrolls under
|
||||
it. Same inset as the content column, so the controls stay aligned. */
|
||||
footer { position: sticky; bottom: 0; z-index: 10; width: 100vw; margin: 1.2rem calc(50% - 50vw) 0; padding: .7rem var(--page-inset) calc(.7rem + env(safe-area-inset-bottom, 0px)); display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: oklch(7% 0.006 95 / 0.82); backdrop-filter: blur(10px); border-top: 1px solid var(--ks-rule); }
|
||||
#steer { flex: 1; min-width: 16rem; background: var(--ks-lacquer-raised); color: var(--ks-text); border: 1px solid var(--ks-rule); border-radius: 7px; padding: .6rem .85rem; font: inherit; }
|
||||
#steer:focus { outline: none; border-color: var(--ks-patina); }
|
||||
/* Build-path toggle: a workflow preference surfaced as a quiet segmented
|
||||
control on the headline row, right-aligned opposite the title, its trade stated in
|
||||
one line that changes with the selection. The default comes from the
|
||||
payload (settings); flipping binds this session only, and the agent
|
||||
learns about a code-to-comp flip live. Rendered only when the payload
|
||||
offers it, which the agent does only when image generation exists. */
|
||||
#build-path { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; flex: none; margin-left: auto; }
|
||||
.bp-switch { display: inline-flex; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; }
|
||||
.bp-note { text-align: right; }
|
||||
.bp-opt { font-family: var(--ks-mono); font-size: .62rem; letter-spacing: .12em; text-transform: uppercase; padding: 7px 12px; background: transparent; border: 0; color: var(--ks-text-faint); cursor: pointer; transition: color .2s ease, background-color .2s ease; }
|
||||
.bp-opt + .bp-opt { border-left: 1px solid var(--ks-rule); }
|
||||
.bp-opt.active { color: var(--ks-dark-ink); background: var(--ks-kinpaku-rich); }
|
||||
.bp-opt:not(.active):hover { color: var(--ks-text); }
|
||||
.bp-note { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .04em; color: var(--ks-text-faint); max-width: 21rem; line-height: 1.5; }
|
||||
/* Flipping to comp starts billed, minutes-long generation, so it asks
|
||||
first; flipping back is free and never does. */
|
||||
#bp-confirm { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; background: oklch(4% 0.004 95 / 0.72); opacity: 0; transition: opacity .2s ease; }
|
||||
#bp-confirm[hidden] { display: none; }
|
||||
#bp-confirm.open { opacity: 1; }
|
||||
.bp-confirm-panel { max-width: 26rem; margin: 1rem; background: var(--ks-lacquer-raised); border: 1px solid var(--ks-rule); border-radius: 10px; padding: 1.4rem 1.5rem 1.3rem; box-shadow: 0 30px 80px oklch(0% 0 0 / 0.55); }
|
||||
.bp-confirm-panel h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; color: var(--ks-champagne); margin-bottom: .55rem; }
|
||||
.bp-confirm-panel p { font-size: .875rem; line-height: 1.55; color: var(--ks-text-muted); }
|
||||
.bp-confirm-actions { display: flex; gap: .6rem; margin-top: 1.1rem; }
|
||||
.bp-confirm-go { background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font: inherit; font-weight: 500; padding: 9px 22px; border-radius: 6px; cursor: pointer; }
|
||||
.bp-confirm-go:hover { background: var(--ks-kinpaku-pale); }
|
||||
.bp-confirm-stay { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font: inherit; padding: 9px 18px; border-radius: 6px; cursor: pointer; }
|
||||
.bp-confirm-stay:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
|
||||
.reroll-btn { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
.reroll-btn:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
.reroll-btn svg { width: 15px; height: 15px; }
|
||||
@@ -752,6 +881,16 @@ function page() {
|
||||
<div id="ambient" aria-hidden="true"></div>
|
||||
<div id="scrim" aria-hidden="true"></div>
|
||||
<div id="lightbox" hidden><img alt=""></div>
|
||||
${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria-labelledby="bp-confirm-title" hidden>
|
||||
<div class="bp-confirm-panel">
|
||||
<h2 id="bp-confirm-title">Flip to comp-first?</h2>
|
||||
<p>The agent starts rendering a comp for every open card right away, about a minute or two per card on your image provider, and the images land on the cards as they finish. This flip binds this session only.</p>
|
||||
<div class="bp-confirm-actions">
|
||||
<button type="button" class="bp-confirm-go" data-confirm>Render comps</button>
|
||||
<button type="button" class="bp-confirm-stay" data-cancel>Keep code-first</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<header>
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>
|
||||
@@ -763,6 +902,13 @@ function page() {
|
||||
<div class="headline">
|
||||
<svg class="headline-die" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>
|
||||
<h1>${esc(payload.title || 'Choose a direction')}</h1>
|
||||
${buildPath?.toggle ? `<div id="build-path" data-default="${buildPath.value}">
|
||||
<div class="bp-switch" role="radiogroup" aria-label="Build path">
|
||||
<button type="button" class="bp-opt" data-bp="comp" role="radio" aria-checked="false">Comp first</button>
|
||||
<button type="button" class="bp-opt" data-bp="code" role="radio" aria-checked="false">Code first</button>
|
||||
</div>
|
||||
<p class="bp-note" data-bp-note></p>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
${payload.question ? `<p class="question">${esc(payload.question)}</p>` : ''}
|
||||
<div class="deck-shell">
|
||||
@@ -822,7 +968,7 @@ function page() {
|
||||
if (count) count.textContent = (at + 1) + '/' + raises.length;
|
||||
// Screen readers hear the raise they just advanced to; the initial
|
||||
// render stays quiet so page load does not narrate every card.
|
||||
if (announce && live) live.textContent = 'Raise ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');
|
||||
if (announce && live) live.textContent = 'Improvement ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');
|
||||
};
|
||||
show(false);
|
||||
const advance = (e) => { e.stopPropagation(); at = (at + 1) % raises.length; show(true); };
|
||||
@@ -863,46 +1009,147 @@ function page() {
|
||||
}));
|
||||
}
|
||||
|
||||
// Sketches stream in after the deal: poll each slot until the file lands,
|
||||
// Comps stream in after the deal: poll each slot until the file lands,
|
||||
// then swap the shimmer for the image. Generation is genuinely slow and a
|
||||
// sequential batch puts the last card many minutes out, so patience is the
|
||||
// default: a slot only shows its inspiration as a stand-in when it has
|
||||
// waited four minutes AND nothing has landed anywhere for four minutes, the
|
||||
// stand-in is labeled as such, and polling continues so the real sketch
|
||||
// stand-in is labeled as such, and polling continues so the real comp
|
||||
// still swaps in whenever it arrives. Progress anywhere resets patience.
|
||||
const landTracker = { last: Date.now() };
|
||||
document.querySelectorAll('.media.sketching').forEach(m => {
|
||||
const url = m.dataset.sketch;
|
||||
const img = m.querySelector('img.sketch');
|
||||
const note = m.querySelector('.sketch-note');
|
||||
const pollComp = (m) => {
|
||||
const url = m.dataset.comp;
|
||||
const img = m.querySelector('img.comp');
|
||||
const note = m.querySelector('.comp-note');
|
||||
const started = Date.now();
|
||||
// A live elapsed count is the difference between "working" and "frozen".
|
||||
const tick = setInterval(() => { if (note) note.textContent = 'rendering · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
|
||||
const settle = () => { clearInterval(tick); m.classList.remove('sketching', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
|
||||
const standIn = () => {
|
||||
const settle = () => { clearInterval(tick); m.classList.remove('comp-pending', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
|
||||
const fallback = () => {
|
||||
const pip = m.querySelector('.pip img');
|
||||
if (!pip || m.classList.contains('stand-in')) return;
|
||||
img.src = pip.getAttribute('src'); img.hidden = false;
|
||||
m.classList.add('stand-in');
|
||||
m.querySelector('.shimmer')?.remove();
|
||||
clearInterval(tick);
|
||||
const label = document.createElement('p');
|
||||
label.className = 'stand-in-label';
|
||||
label.textContent = 'inspiration · comp pending';
|
||||
m.appendChild(label);
|
||||
if (pip) {
|
||||
if (m.classList.contains('stand-in')) return false;
|
||||
img.src = pip.getAttribute('src'); img.hidden = false;
|
||||
m.classList.add('stand-in');
|
||||
m.querySelector('.shimmer')?.remove();
|
||||
clearInterval(tick);
|
||||
const label = document.createElement('p');
|
||||
label.className = 'stand-in-label';
|
||||
label.textContent = 'inspiration · comp pending';
|
||||
m.appendChild(label);
|
||||
return false;
|
||||
}
|
||||
|
||||
// No comp and no inspiration is the text-only card the payload would
|
||||
// have rendered without a comp declaration. Bring the complete read
|
||||
// forward before removing the now-unreachable back face.
|
||||
const card = m.closest('.card');
|
||||
const front = card?.querySelector('.face.front');
|
||||
const body = front?.querySelector('.body');
|
||||
const back = card?.querySelector('.face.back');
|
||||
const textOnlyFacts = m.querySelector('template.text-only-facts');
|
||||
const choose = body?.querySelector(':scope > button.choose');
|
||||
if (body && textOnlyFacts && choose) {
|
||||
const plainDetail = body.querySelector(':scope > .detail:not(.more)');
|
||||
[...body.children].filter((el) => el.classList.contains('fact') || el.matches('.detail.more')).forEach((el) => el.remove());
|
||||
choose.before(textOnlyFacts.content.cloneNode(true));
|
||||
if (plainDetail) choose.before(plainDetail);
|
||||
}
|
||||
card?.classList.remove('flipped');
|
||||
front?.classList.add('text-only');
|
||||
back?.remove();
|
||||
settle();
|
||||
m.remove();
|
||||
return true;
|
||||
};
|
||||
const tryLoad = () => {
|
||||
// A slot the user flipped back out of leaves the DOM; let its loop die.
|
||||
if (!m.isConnected) { clearInterval(tick); return; }
|
||||
const probe = new Image();
|
||||
probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); };
|
||||
probe.onerror = () => {
|
||||
const quiet = Date.now() - landTracker.last > 240000;
|
||||
if (Date.now() - started > 240000 && quiet) standIn();
|
||||
if (Date.now() - started > 240000 && quiet && fallback()) return;
|
||||
setTimeout(tryLoad, m.classList.contains('stand-in') ? 5000 : 2500);
|
||||
};
|
||||
probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||
};
|
||||
tryLoad();
|
||||
});
|
||||
};
|
||||
document.querySelectorAll('.media.comp-pending').forEach(pollComp);
|
||||
|
||||
// Build-path toggle: the default is the round's recorded preference and
|
||||
// flipping binds this session only. Flipping code to comp swaps every
|
||||
// reserve slot (data-comp-slot) to its shimmer and tells the server, so
|
||||
// the waiting agent starts generating; flipping back is free: pending
|
||||
// slots return to their wireframes, a comp that already landed stays.
|
||||
const bp = document.getElementById('build-path');
|
||||
if (bp) {
|
||||
const notes = {
|
||||
comp: 'An image sets the bar first and the build must match it. Bolder composition; comps render before code.',
|
||||
code: 'Code builds directly; the ambition is written into the contract and audited at the finish. Leaner, faster.',
|
||||
};
|
||||
const noteEl = bp.querySelector('[data-bp-note]');
|
||||
let current = bp.dataset.default;
|
||||
const set = (value) => {
|
||||
current = value;
|
||||
bp.querySelectorAll('.bp-opt').forEach(b => {
|
||||
const on = b.dataset.bp === value;
|
||||
b.classList.toggle('active', on);
|
||||
b.setAttribute('aria-checked', String(on));
|
||||
});
|
||||
if (noteEl) noteEl.textContent = notes[value];
|
||||
};
|
||||
set(current);
|
||||
const enterComp = () => {
|
||||
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
|
||||
const front = card.querySelector('.face.front');
|
||||
if (!front || front.querySelector('.media.comp-pending') || front.querySelector('.media img.comp:not([hidden])')) return;
|
||||
const m = document.createElement('div');
|
||||
m.className = 'media comp-pending';
|
||||
m.dataset.comp = card.dataset.compSlot;
|
||||
m.innerHTML = '<div class="shimmer"><span class="comp-note">rendering…</span></div><img class="comp" alt="" hidden>';
|
||||
const wireEl = front.querySelector('.media.wire');
|
||||
if (wireEl) { wireEl.hidden = true; front.insertBefore(m, wireEl); }
|
||||
else { front.classList.remove('text-only'); front.insertBefore(m, front.querySelector('.body')); }
|
||||
pollComp(m);
|
||||
});
|
||||
};
|
||||
const exitComp = () => {
|
||||
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
|
||||
const front = card.querySelector('.face.front');
|
||||
const pending = front?.querySelector('.media.comp-pending');
|
||||
if (!pending) return; // landed comps stay; they exist either way
|
||||
pending.remove();
|
||||
const wireEl = front.querySelector('.media.wire');
|
||||
if (wireEl) wireEl.hidden = false;
|
||||
else if (!front.querySelector('.media')) front.classList.add('text-only');
|
||||
});
|
||||
};
|
||||
const apply = (value) => {
|
||||
set(value);
|
||||
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
|
||||
if (value === 'comp') enterComp(); else exitComp();
|
||||
};
|
||||
// Flipping to comp starts real generation, so it confirms first; the
|
||||
// flip back is free and applies immediately.
|
||||
const confirm = document.getElementById('bp-confirm');
|
||||
const closeConfirm = () => { confirm.classList.remove('open'); confirm.hidden = true; };
|
||||
confirm.querySelector('[data-confirm]').addEventListener('click', () => { closeConfirm(); apply('comp'); });
|
||||
confirm.querySelector('[data-cancel]').addEventListener('click', closeConfirm);
|
||||
confirm.addEventListener('click', (e) => { if (e.target === confirm) closeConfirm(); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !confirm.hidden) closeConfirm(); });
|
||||
bp.querySelectorAll('.bp-opt').forEach(b => b.addEventListener('click', () => {
|
||||
const value = b.dataset.bp;
|
||||
if (value === current) return;
|
||||
if (value === 'comp') {
|
||||
confirm.hidden = false;
|
||||
requestAnimationFrame(() => confirm.classList.add('open'));
|
||||
return;
|
||||
}
|
||||
apply(value);
|
||||
}));
|
||||
}
|
||||
|
||||
// A declared image that never loads (missing catalog asset, offline shell)
|
||||
// must not sit as a dark void: the slot collapses to the card's own
|
||||
@@ -910,7 +1157,7 @@ function page() {
|
||||
// slots are excluded; their polling owns the wait.
|
||||
const artFailed = (img) => {
|
||||
const m = img.closest('.media');
|
||||
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
|
||||
if (!m || m.classList.contains('comp-pending') || m.classList.contains('unavailable')) return;
|
||||
m.classList.add('unavailable');
|
||||
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
|
||||
if (colors.length) m.style.background = 'linear-gradient(135deg, ' + colors.map((c, i) => c + ' ' + Math.round(i * 100 / colors.length) + '% ' + Math.round((i + 1) * 100 / colors.length) + '%').join(', ') + ')';
|
||||
@@ -923,7 +1170,7 @@ function page() {
|
||||
label.textContent = 'artwork unavailable';
|
||||
m.appendChild(label);
|
||||
};
|
||||
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
|
||||
document.querySelectorAll('.media:not(.comp-pending) > img').forEach(img => {
|
||||
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
||||
else img.addEventListener('error', () => artFailed(img), { once: true });
|
||||
});
|
||||
@@ -1109,6 +1356,26 @@ const server = http.createServer((req, res) => {
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/build-path') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end('{"ok":true}');
|
||||
let value = null;
|
||||
try { value = JSON.parse(body).value; } catch { /* ignore */ }
|
||||
if (value !== 'comp' && value !== 'code') return;
|
||||
const wasComp = liveBuildPath === 'comp';
|
||||
liveBuildPath = value;
|
||||
// Only a flip TO comp needs the agent mid-round: comps must start
|
||||
// rendering into the declared slots. The reverse is free.
|
||||
if (detachedKey && value === 'comp' && !wasComp) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/answer') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
@@ -1129,7 +1396,8 @@ const server = http.createServer((req, res) => {
|
||||
...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}),
|
||||
...(followupOpen ? { followup: true } : {}),
|
||||
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||
...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
|
||||
...((chosen?.comp ?? chosen?.sketch) ? { comp: chosen.comp ?? chosen.sketch } : {}),
|
||||
...(liveBuildPath && !isReroll ? { buildPath: liveBuildPath, buildPathFlipped: liveBuildPath !== (buildPathDefault?.value ?? null) } : {}),
|
||||
});
|
||||
if (detachedKey) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
|
||||
@@ -18,7 +18,7 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh
|
||||
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ An open direction round owns the word first: "bolder" said while a direction dec
|
||||
|
||||
## Scope is sovereign
|
||||
|
||||
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, stop and STOP and call the AskUserQuestion tool to clarify. before expanding it, naming the exact addition and the job it would do.
|
||||
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, do not expand it on your own. STOP and call the AskUserQuestion tool to clarify. Name the exact addition and the job it would do.
|
||||
|
||||
## Why it reads flat
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ Resolve one stable target, run two independent assessments, synthesize a design
|
||||
- Viewable targets require browser inspection when available.
|
||||
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
|
||||
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
|
||||
- The question is the LAST thing in the response. Write the entire report out first, then ask; nothing follows the question. Prose emitted after a structured question is withheld until the user answers it, so a report written after the question reads as if the critique never ran.
|
||||
- A run that ends with neither the targeted questions nor a literal `Questions skipped: <reason>` line is an incomplete run. The report is not the finish; the close is.
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -172,6 +174,14 @@ Provocative questions that might unlock better solutions:
|
||||
- Prioritize ruthlessly. If everything is important, nothing is.
|
||||
- Don't soften criticism. Developers need honest feedback to ship great design.
|
||||
|
||||
### Deliver the Report
|
||||
|
||||
Write the full report into the chat response now, before any persistence work. This is the deliverable; everything below it is bookkeeping.
|
||||
|
||||
Do this first because the alternative is the most common way this command fails: the report gets composed once, straight into the persistence heredoc, and the run ends with a perfect archive nobody has read. Composing it into a file is not delivering it. If the report exists only in `.impeccable/critique/`, the run produced nothing.
|
||||
|
||||
Persistence is not the end of the run. After it, the response continues with the trend line and the close.
|
||||
|
||||
### Persist the Snapshot
|
||||
|
||||
Once the report above is finalized, write it to `.impeccable/critique/` so the user can refer back, and so `/impeccable polish` can pick up the priority issues without a copy-paste.
|
||||
@@ -180,6 +190,8 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
|
||||
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, design-specificity verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
|
||||
|
||||
This is a copy of the report you already delivered above, for later commands to read. It is not delivery. If you find yourself composing the report for the first time inside this heredoc, you have skipped Deliver the Report; go back and send it.
|
||||
|
||||
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
|
||||
```bash
|
||||
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
|
||||
@@ -204,12 +216,16 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
|
||||
If this is the first run for the slug, the trend is just one score; say so: "First run for this target, no trend yet."
|
||||
|
||||
6. **Close the run.** Go to Ask the User below and emit the questions, or the `Questions skipped: <reason>` line when the count allows it. The run is not complete until you do. Persistence is bookkeeping and cleanup is not an ending; stopping here leaves the user with a report and no way forward, and leaves `/impeccable polish` with no priorities to inherit.
|
||||
|
||||
This is fire-and-forget. Do not show the user the helper's JSON output; only the human-readable trend line and the written path. Failures here should not block the rest of the flow; print the error and move on.
|
||||
|
||||
### Ask the User
|
||||
|
||||
**After presenting findings**, use targeted questions based on what was actually found. STOP and call the AskUserQuestion tool to clarify. These answers will shape the action plan.
|
||||
|
||||
Ask in the same message that carries the report, with the report written out first and the question last. Do not split the two across turns: a turn that ends on the report is a turn that ends, and the questions never arrive. Order within the message is what matters, because prose emitted after a structured question is withheld until the user answers.
|
||||
|
||||
Ask questions along these lines (adapt to the specific findings; do NOT ask generic questions):
|
||||
|
||||
1. **Priority direction**: Based on the issues found, ask which category matters most to the user right now. For example: "I found problems with visual hierarchy, color usage, and information overload. Which area should we tackle first?" Offer the top 2-3 issue categories as options.
|
||||
@@ -224,7 +240,9 @@ Ask questions along these lines (adapt to the specific findings; do NOT ask gene
|
||||
- Every question must reference specific findings from the report. Never ask generic "who is your audience?" questions.
|
||||
- Keep it to 2-4 questions maximum. Respect the user's time.
|
||||
- Offer concrete options, not open-ended prompts.
|
||||
- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Recommended Actions.
|
||||
- Skipping is allowed only when the report listed **fewer than 3 Priority Issues**. Count them; do not judge the findings "straightforward" by feel. At 3 or more, the questions are required.
|
||||
|
||||
**Final-question gate.** The user-visible response must either include the targeted questions or carry the literal line `Questions skipped: <reason>` naming the count that permitted the skip. Each question must include 2-3 concrete answer options tied to the actual critique findings. Do not end with only open-ended questions, and do not end with neither: stopping after the report, having asked nothing and printed no skip line, is the most common way this command fails.
|
||||
|
||||
### Recommended Actions
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh
|
||||
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Analyze what makes the design feel complex or cluttered:
|
||||
- What can be removed, hidden, or combined?
|
||||
- What's the 20% that delivers 80% of value?
|
||||
|
||||
If any of these are unclear from the codebase, STOP and call the AskUserQuestion tool to clarify.
|
||||
If any of these are unclear from the codebase, do not guess. STOP and call the AskUserQuestion tool to clarify.
|
||||
|
||||
**CRITICAL**: Simplicity is not about removing features. It's about removing obstacles between users and their goals. Every element should justify its existence.
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ The same restraint applies to `workspace-context-inherited`. Inheritance is a de
|
||||
|
||||
- `workspace-platform-native-evidence` is the finding that matters most here: a workspace carrying native build files while inheriting a root record that resolves to web gets web guidance for its whole life and never loads [ios.md](ios.md) or [android.md](android.md). The repair is a child PRODUCT.md in that workspace, because one inherited record cannot hold two platforms.
|
||||
- `config-project-roots-match-nothing` means every `projectRoots` glob missed, so the repo root is silently standing in as the active project. A renamed workspace directory is the usual cause. Report the patterns and ask which directories they should name.
|
||||
- `config-invalid-build-path` and `config-build-path-unset` both concern one key, `buildPath` in `.impeccable/config.json` (or the gitignored `.impeccable/config.local.json`, which wins for that developer). It holds `comp` or `code` and sets whether new surfaces are built from a generated comp or straight in code. An unread value does not fall back to the opposite path, so a project meaning `code` has been building comp-led; report the exact value. The unset finding fires only where a project has done direction work and never recorded a preference, and the offer belongs in it only when image generation exists in your tool surface. Without image generation there is nothing to choose and nothing to say.
|
||||
- Use the `workspaces` table to show the user which apps carry their own context, which inherit, and which have none, before proposing any change.
|
||||
|
||||
## Opting out of the boot check
|
||||
|
||||
@@ -68,7 +68,7 @@ Omit irrelevant sections rather than filling them with invented rules. Put respo
|
||||
- An existing `DESIGN.md` is stale (the design has drifted).
|
||||
- Before a large redesign, to capture the current state as a reference.
|
||||
|
||||
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file and STOP and call the AskUserQuestion tool to clarify. whether to refresh, overwrite, or merge.
|
||||
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file first. STOP and call the AskUserQuestion tool to clarify. The choice is refresh, overwrite, or merge.
|
||||
|
||||
## Two paths
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Identify reusable patterns, components, and design tokens, then extract and cons
|
||||
|
||||
Find the design system, component library, or shared UI directory. Understand its structure: component organization, naming conventions, design token structure, import/export conventions.
|
||||
|
||||
**CRITICAL**: If no design system exists, STOP and call the AskUserQuestion tool to clarify. before creating one. Understand the preferred location and structure first.
|
||||
**CRITICAL**: If no design system exists, do not create one yet. STOP and call the AskUserQuestion tool to clarify. Understand the preferred location and structure first.
|
||||
|
||||
## Step 2: Identify Patterns
|
||||
|
||||
|
||||
@@ -107,9 +107,11 @@ When the platform you just recorded is `ios`, `android`, or `adaptive`, load [io
|
||||
|
||||
Before loading new-work or resuming shape/build, verify that PRODUCT.md exists at the resolved path and contains the confirmed product record. If the file is absent, init is incomplete. Do not substitute interview notes, a planning packet, or later design prose for the file.
|
||||
|
||||
## Step 5: Configure live mode when useful
|
||||
## Step 5: Record workflow defaults
|
||||
|
||||
Skip native or non-runnable projects and leave existing config untouched. Otherwise follow [live.md](live.md)'s first-time setup. Any CSP source edit still requires its stated consent.
|
||||
When image generation is available (context.mjs reports it) and no `buildPath` is recorded yet, ask once how new surfaces should be built, stated as the trade it is: **comp-first** (an image sets the bar before any code; bolder composition, slower, and the build must match the image) or **code-first** (build directly; the ambition is written into the direction contract and audited at the finish; leaner, faster). Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, merging with the keys already there. A value already recorded in `.impeccable/config.json` or the gitignored `.impeccable/config.local.json` is a confirmed answer: on a re-run, honor it in silence rather than asking again. This is a default, not a lock: the decision page renders a toggle whose flip binds a single session and is never written back. Without image generation there is no choice to record; code-first is the only path.
|
||||
|
||||
Then configure live mode when useful: skip native or non-runnable projects and leave existing config untouched. Otherwise follow [live.md](live.md)'s first-time setup. Any CSP source edit still requires its stated consent.
|
||||
|
||||
## Step 6: Wrap up or resume
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Keep the visual system fixed. Derive five to seven materially different structur
|
||||
|
||||
`node .claude/skills/impeccable/scripts/concept-seed.mjs --scope surface --mode <mode>`
|
||||
|
||||
The script assigns which structure gets built; your top-ranked structure is what every run would ship, so the dice come from outside. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
||||
The script deals three of your structures to the table; the dice decide which three reach the user, so the ranking rut stays broken while the user still holds a real choice. Present the three dealt structures on the decision page as full cards of equal salience, the dealt lead carrying kicker THE ROLL, with steer and re-roll; the user locks one in. No canon card and no pick card at surface scope: the world is settled, so every card visualizes composition, not identity. With image generation available and a comp-led default (the build-path paragraph below: `.impeccable/config.json`, the toggle handles the exception), each card declares a `comp` under `.impeccable/mocks/decision/`, generated after serving in reading order under the comp discipline in [visualize.md](visualize.md); anchor each of these comps on the established identity by passing a captured screenshot of a representative existing page as a reference image (the harness image tool's input image, or `generate-image.mjs --ref`) beside a prompt that leads with the new surface's structure and names DESIGN.md's palette, type, and component character, because a prose paraphrase of a design system drifts where a pixel reference does not. Without image generation, or under a code-led default, each card instead carries a `wireframe` layout schematic (see `serve-question.mjs --schema`) that the page draws itself. Locking a card is the approval and sets the build path: a locked comp builds comp-led with that comp as the approved comp, discharging [visualize.md](visualize.md)'s three-option round with no second approval point; a locked wireframe builds code-led, its ambition carried by the direction contract. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
||||
|
||||
### Create or replace the visual world
|
||||
|
||||
@@ -44,13 +44,13 @@ 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, not only its physical objects: the notation, publications, identity programs, data graphics, and interfaces it reads daily; a nameable abstract system (a school of poster, a documentation standard) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; dig until the list spans at least three families.
|
||||
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
|
||||
4. Run `node .claude/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point. The weighing closes with a verdict per challenger, decided before any borrowing is considered: wins (beats the assigned direction on both axes; it becomes the build candidate), competitive (holds one axis; it stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a motif lifted from a declined world is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker MY PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
|
||||
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `followup: true` when the execution-contract round will follow (it does whenever image generation exists and no standing build-path preference is recorded); a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (the build-path paragraph below owns the details); a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. The fallback to the structured question tool is never yours to predict: run the script, and only exit code 2 from starting it routes the decision there; treat that exit as the fallback, never as an error to retry.
|
||||
|
||||
When image generation exists, every card also declares a `sketch` path under `.impeccable/mocks/decision/` (the field keeps its wire name for compatibility; what it carries is the card's comp), the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished sketch pays sketch quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way; visualize.md's self-checks bind decision comps identically. Generation takes the same time at any fidelity, so an unfinished draft pays draft quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
|
||||
The moment the direction lands, one more round on the same open table decides the execution contract. The direction payload declares `followup: true`, so the table stays open after the pick; deliver the build-path payload through `--update` immediately. Two text-only cards. **Comp-led**: a first-viewport comp is generated and it is law, the finish review audits the build against it; boldest composition on the table, fix rounds expected, motion at risk; choosing it makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. Lead with the chosen world's fit: a costume-heavy catalog world leads comp-led, a quiet or conventional direction leads code-led; the lead is a default, never a decision, and the user flips it freely. A standing preference, voiced once, is recorded as a brand commitment in PRODUCT.md and skips this round on later surfaces. Without image generation there is no fork and no round: code-led is the only path, stated in one line rather than asked. Only a detached table (`--start`) stays open for `--update`: a blocking serve or the structured-tool channel runs the build-path round as its own second question instead, and `followup: true` belongs only on a detached round.
|
||||
The execution contract, comp-led or code-led, is a workflow preference, not a per-surface decision, so no round asks it: the recorded default rides every round and the page's toggle handles the exception. Read the default from `.impeccable/config.json` (`buildPath`), with the gitignored `.impeccable/config.local.json` winning where one machine differs from the team's committed value; with neither, comp-led is the default whenever image generation exists. Author every direction and surface payload with `buildPath: { "value": <default>, "toggle": true }`; the page renders a footer toggle with the trade stated beside it, and the ANSWER returns `buildPath` plus `buildPathFlipped`. A flipped value binds that session only and is never written back, with one exception, and it is the only thing inside a round that earns a question about this preference (init records it up front on projects that get the chance): when `buildPathFlipped` comes back true on a project that records no `buildPath` at all, ask once after the round closes whether to keep it as the standing default. Either answer ends in a write to `.impeccable/config.json`; the answer picks the value, never whether to record one. Yes writes the flipped value, and "no, just this once" writes the value they flipped away from, which is the standing default they just confirmed by declining. Ask on the flip and never on the untouched default, because a user who left the toggle alone has told you nothing. A declined offer nothing writes down is an offer the next session makes again. When the user asks in words to change the standing default, update the file without asking. **Comp-led**: the chosen card's comp is law, generated before building when it does not exist yet, and the finish review audits the build against it; boldest composition on the table, fix rounds expected; comp-led makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. A code-led round still declares each card's comp path as a flip reserve: when the user flips the toggle to comp mid-round, `--wait` returns once with BUILD PATH FLIPPED while the page shimmers the slots; generate each open card's comp into its declared path then, lead first, and wait again. The flip back is free, and a comp that already rendered rides at the finish review as the critique reference. Without image generation there is no toggle and no choice: code-led is the only path, stated in one line rather than asked. The old two-card execution-contract round is retired; `followup: true` remains the general mechanism for delivering any later round over the same table via `--update`.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Push an interface past conventional limits. This isn't just about visual effects
|
||||
This command has the highest potential to misfire. Do NOT jump straight into implementation. You MUST:
|
||||
|
||||
1. **Think through 2-3 different directions**: consider different techniques, levels of ambition, and aesthetic approaches. For each direction, briefly describe what the result would look and feel like.
|
||||
2. **STOP and call the AskUserQuestion tool to clarify.** to present these directions and get the user's pick before writing any code. Explain trade-offs (browser support, performance cost, complexity).
|
||||
2. **Get the user's pick before writing any code.** STOP and call the AskUserQuestion tool to clarify. Carry each direction's description and its trade-offs (browser support, performance cost, complexity) inside the option itself, so the user is choosing between things they can read. A structured question blocks the message it rides in until the user answers, so directions written alongside the question stay invisible while the user is being asked to choose between them.
|
||||
3. Only proceed with the direction the user confirms.
|
||||
|
||||
Skipping this step risks building something embarrassing that needs to be thrown away.
|
||||
|
||||
@@ -28,7 +28,7 @@ Analyze what makes the design feel too intense:
|
||||
- What's working? (Don't throw away good ideas)
|
||||
- What's the core message? (Preserve what matters)
|
||||
|
||||
If any of these are unclear from the codebase, STOP and call the AskUserQuestion tool to clarify.
|
||||
If any of these are unclear from the codebase, do not guess. STOP and call the AskUserQuestion tool to clarify.
|
||||
|
||||
**CRITICAL**: "Quieter" doesn't mean boring or generic. It means refined and easier on the eyes. Think luxury, not laziness.
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# Visualize: Direction Comps & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it. A surface-scope structure round that already put three visualized cards before the user (new-work.md, established world) has discharged this round: the locked card’s comp is the approved comp, so record the approval and continue at After approval; generate nothing new.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate three compositional options
|
||||
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here.
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. On an established world, anchor every comp on the real identity: capture a screenshot of a representative existing page and pass it as a reference image (the harness image tool’s input image, or `generate-image.mjs --ref`); the prompt then leads with the new surface’s structure while the reference carries palette, type, and component character, because DESIGN.md words alone drift where a pixel reference does not. Name what the reference contributes and what it must not: chrome, palette, type, and component character carry over; the reference page’s own content does not, so a banner, hero, or card lifted verbatim from the reference is the reference leaking, not fidelity. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here.
|
||||
|
||||
- A comp is a designed surface, not a picture of the subject. Lead the generation prompt with the surface's own structure, whatever regions this design actually has, named in order with their scale relationships; a page with no navigation states that instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with the world's atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph or scene with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally.
|
||||
- The inverse is also a failure: a surface with none of its subject in it. The subject appears as the content the regions exist to hold; the world dresses the frame and never displaces what the frame exists to show. The deletion usually rides in on the prompt's exclusion list, so exclusions bind invented claims, and a medium ban belongs to the committed imagery stance, never to caution. Before accepting a render, point at the subject: a render that depicts everything about the world and nothing of the subject fails however faithful its atmosphere, so regenerate with the subject's content named region by region.
|
||||
- A comp is judged as the shipped screen: the visitor's job must be readable from the image alone. Name the surface's mode from the render with no caption; a render whose mode cannot be read back is art direction without a surface, so regenerate with the visitor's job as the prompt's spine.
|
||||
- Commitment is depth, not coverage. The world enters through one dominant move plus the material, type, and spacing that support it, and the remaining regions hold still so that move can be read; a region that simply does its job in the world's own grammar carries the direction further than a region performing the concept. The check cuts competition, never content: a quieted region keeps its information and stops performing. Where the direction names a focal moment, a second element competing with it at the same scale means the comp is shouting; where it names none, several regions performing the concept at once is the same shout. Regenerate keeping the strongest move and quieting the rest. Busy is louder, not bolder.
|
||||
- When the user shortlisted multiple concepts, spread the three across them.
|
||||
- When 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.
|
||||
@@ -18,7 +21,7 @@ Treat each comp as a direction test, not a screenshot specification. Core UI tex
|
||||
|
||||
## One approval point
|
||||
|
||||
Show the three together: in the harness when it can display images, otherwise on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero). Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
Show the three together on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero), or in the harness only when it renders images inline; a text-only surface does not count as display. Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
|
||||
@@ -338,6 +338,20 @@ export function renderConceptSeed({
|
||||
};
|
||||
const indexSalt = reroll === 0 ? 'index' : `index:reroll-${reroll}`;
|
||||
const buildIndex = 3 + Math.floor(unit(indexSalt) * (candidateCount - 2)); // 3..candidateCount
|
||||
// Surface scope deals a hand of three grounded structures: one card is not
|
||||
// a choice, and the full ranked list would hand selection back to the
|
||||
// model's taste. The dice pick all three; the primary index leads. The
|
||||
// no-lineup rule stays direction-only, where it was written for worlds.
|
||||
const dealtIndices = [buildIndex];
|
||||
for (let draw = 0; scope === 'surface' && dealtIndices.length < Math.min(3, candidateCount); draw += 1) {
|
||||
const idx = 1 + Math.floor(unit(`${indexSalt}:deal-${draw}`) * candidateCount);
|
||||
if (!dealtIndices.includes(idx)) dealtIndices.push(idx);
|
||||
if (draw > 64) { // hash repeats cannot stall the deal
|
||||
for (let fill = 1; dealtIndices.length < Math.min(3, candidateCount); fill += 1) {
|
||||
if (!dealtIndices.includes(fill)) dealtIndices.push(fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local catalog first (private repo, evals, tests), then the roll API,
|
||||
// then a degraded assignment-only seed. The assigned index is pure local
|
||||
@@ -404,19 +418,31 @@ export function renderConceptSeed({
|
||||
interaction and state, and a substantially different future surface. In an
|
||||
attended run, present the assigned direction fully committed and offer
|
||||
re-roll. You may add ONE card for your top-ranked grounded candidate when
|
||||
it is not the assigned direction, kicker MY PICK, with an honest risk line
|
||||
it is not the assigned direction, kicker IMPECCABLE’S PICK, with an honest risk line
|
||||
naming its familiarity; one pick card, never a ranked lineup, and the pick
|
||||
never takes the lead position. When the assignment IS your top candidate,
|
||||
there is no pick card. Re-roll yourself only
|
||||
on named factual grounds, when the assignment cannot carry the product's
|
||||
truth or task; taste is never grounds.`
|
||||
: `After ordering the task's grounded structural candidates by resonance,
|
||||
build candidate ${buildIndex} of your own grounded list; the assignment never
|
||||
points at a challenger. The assignment is the roll, not a suggestion.
|
||||
In an attended run, present the assigned structure and offer re-roll; never
|
||||
present a ranked lineup to choose from. Re-roll yourself only when the
|
||||
assignment fails audience identification or product clarity on named
|
||||
factual grounds.`;
|
||||
deal candidates ${dealtIndices.join(', ')} of your own grounded list to the
|
||||
table; index ${buildIndex} leads, and the deal never points at a challenger.
|
||||
The deal is the roll, not a suggestion: the dice decide which structures
|
||||
reach the user, so the ranking rut stays broken while the user still gets a
|
||||
real choice, and the full ranked list stays yours. In an attended run,
|
||||
present the three dealt structures as full cards of equal salience, the
|
||||
lead carrying kicker THE ROLL, with steer and re-roll, and let the user
|
||||
lock one in; the world is already settled, so this choice is composition.
|
||||
Visualize every dealt card: with image generation available and a
|
||||
comp-led default (.impeccable/config.json buildPath; the page toggle
|
||||
handles the exception), declare a comp per card and generate after
|
||||
serving, lead first; otherwise author each card's wireframe field (see
|
||||
serve-question --schema) and the page draws the schematic. Carry the
|
||||
recorded default in the payload as buildPath with toggle: true. Locking a card
|
||||
approves its comp: a surface round that put three visualized structures on
|
||||
the table replaces the three-option comp round in visualize.md. Re-roll
|
||||
yourself only when every dealt structure fails audience identification or
|
||||
product clarity on named factual grounds.`;
|
||||
|
||||
const challengerInstruction = scope === 'direction'
|
||||
? `Fuse each challenger before judging it: the challenger supplies the form
|
||||
@@ -490,7 +516,7 @@ assigned index is suspended this round and the user picks; seed key ${key}.
|
||||
`
|
||||
: '';
|
||||
return `${degradedHeader}
|
||||
${degradedRegister}ASSIGNED INDEX: ${buildIndex}
|
||||
${degradedRegister}${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
|
||||
${promotedInstruction}
|
||||
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.
|
||||
@@ -514,8 +540,11 @@ channel: when a browser can open, present the direction on the decision page
|
||||
the no-browser fallback.
|
||||
${authorityInstruction}
|
||||
A user- or brief-pinned decision beats the roll, always.
|
||||
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.
|
||||
${scope === 'direction'
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||
: `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index
|
||||
${buildIndex} leads. Present all three dealt structures; seed key ${key}.`}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -595,7 +624,7 @@ rivals to your habitual layout, and keep only what makes this product clearer.${
|
||||
or IMPECCABLE_NO_TELEMETRY is set.\n`
|
||||
: '';
|
||||
const assignedBlock = register === null
|
||||
? `ASSIGNED INDEX: ${buildIndex}
|
||||
? `${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
|
||||
${promotedInstruction}
|
||||
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.`
|
||||
@@ -621,8 +650,11 @@ craft bar, the finish level and commitment the build is expected to reach,
|
||||
never as a mockup to copy; your surface serves this product, not that render.
|
||||
`;
|
||||
const restated = register === null
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
? (scope === 'direction'
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||
: `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index
|
||||
${buildIndex} leads. Present all three dealt structures; seed key ${key}.`)
|
||||
: `REGISTER (restated for truncated readers): ${register}, user-requested; the
|
||||
assigned index is suspended this round; seed key ${key}.`;
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
|
||||
|
||||
@@ -42,14 +42,15 @@ function latestCritique(cwd) {
|
||||
if (!latest) return null;
|
||||
const get = (key) => latest.meta[key] ?? null;
|
||||
const num = (v) => {
|
||||
if (v == null || (typeof v === 'string' && v.trim() === '')) return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
return {
|
||||
slug: get('slug'),
|
||||
score: num(get('score')),
|
||||
p0: num(get('p0')),
|
||||
p1: num(get('p1')),
|
||||
score: num(get('total_score') ?? get('score')),
|
||||
p0: num(get('p0_count') ?? get('p0')),
|
||||
p1: num(get('p1_count') ?? get('p1')),
|
||||
timestamp: get('timestamp'),
|
||||
file: path.relative(cwd, latest.path),
|
||||
};
|
||||
|
||||
@@ -1013,14 +1013,22 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
// still spelled out as the next command. The offer stays; the command leaves
|
||||
// this turn entirely, because installing over the skill mid-session changes
|
||||
// files the session is reading and only takes effect in the next one anyway.
|
||||
function buildUpdateDirective(localVersion, latestVersion) {
|
||||
return (
|
||||
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
|
||||
`(installed v${localVersion}, latest v${latestVersion}). ` +
|
||||
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
|
||||
`Mention it once, in this form: "A newer Impeccable (v${latestVersion}) is available. ` +
|
||||
`Update now? It runs \`npx impeccable update\`." ` +
|
||||
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
|
||||
`Either way, continue the current task without waiting, and do not raise this again.`
|
||||
`Do not run \`npx impeccable update\` in this turn, whatever the user answers: it rewrites the skill files ` +
|
||||
`this session is reading, and the update only takes effect in the next session, so there is nothing to gain now. ` +
|
||||
`Run it in a later turn, only after the user has asked for it in their own words. ` +
|
||||
`Continue the current task now without waiting, and do not raise this again.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1142,6 +1150,7 @@ async function cli() {
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
appendDetectorFallback(parts, ctx);
|
||||
appendImageGenDirective(parts);
|
||||
appendBuildPathDirective(parts, ctx);
|
||||
appendAutonomyCounterDirective(parts);
|
||||
appendSubagentAuthorizationDirective(parts);
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
@@ -1161,6 +1170,7 @@ async function cli() {
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
appendDetectorFallback(parts, ctx);
|
||||
appendImageGenDirective(parts);
|
||||
appendBuildPathDirective(parts, ctx);
|
||||
appendAutonomyCounterDirective(parts);
|
||||
appendSubagentAuthorizationDirective(parts);
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
@@ -1269,6 +1279,53 @@ function automaticHookMode(ctx) {
|
||||
}
|
||||
|
||||
|
||||
// Build-path preference: a workflow setting (comp-led vs code-led), read here
|
||||
// so every session starts knowing it without a file hunt. It rides the unified
|
||||
// config beside the hook and detector settings, and the gitignored local file
|
||||
// wins, because whether a machine has an image tool is a property of that
|
||||
// machine, not of the team's committed default. Absence stays silent;
|
||||
// new-work's own default applies, and the decision page toggle can flip the
|
||||
// value for a single session.
|
||||
function readBuildPathAt(root) {
|
||||
let value = null;
|
||||
let source = null;
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = readJson(path.join(root, '.impeccable', name));
|
||||
if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') {
|
||||
value = raw.buildPath;
|
||||
source = `.impeccable/${name}`;
|
||||
}
|
||||
}
|
||||
return value ? { value, source } : null;
|
||||
}
|
||||
|
||||
// Roots in precedence order, nearest first: the resolved project decides, and
|
||||
// the repo root is the fallback a monorepo commits once for every app in it.
|
||||
// `checkBuildPathUnset` reads exactly these two, and the pair has to match:
|
||||
// when they disagree the finding goes silent because a value exists while the
|
||||
// directive never names it, which is the one combination nobody can debug.
|
||||
//
|
||||
// The invoking directory is deliberately not in the chain. With `--target`
|
||||
// selecting another workspace, cwd is the caller's app, not the target's, and
|
||||
// letting it rank above the repo root hands one workspace another's workflow.
|
||||
// It stands in only when no project resolved at all.
|
||||
function appendBuildPathDirective(parts, ctx) {
|
||||
const roots = [...new Set(
|
||||
[ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((root) => path.resolve(root)),
|
||||
)];
|
||||
for (const root of roots) {
|
||||
const found = readBuildPathAt(root);
|
||||
if (!found) continue;
|
||||
// "Never written back" is scoped by the fact that this directive exists at
|
||||
// all: it is emitted only where a value is already recorded, which is the
|
||||
// case where a flip really is session-only. Saying so inline because the
|
||||
// bare absolute reads as a rule that overrides new-work's one-time offer,
|
||||
// which is exactly how the same wording misfired in serve-question.
|
||||
parts.push(`BUILD_PATH_DEFAULT: ${found.value} (from ${found.source}). Author direction and surface rounds with this as buildPath.value and toggle: true; a flip on the page binds that session only and is never written back, because a default is already recorded here. New-work's one-time offer to record a flipped value applies only where no default exists, which is why you are not seeing this line on those projects.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Image generation availability: harness-native tools always win, but when the
|
||||
// environment carries an OpenAI key the API fallback works everywhere. The
|
||||
// flag only reports capability, positively: absence stays silent, because a
|
||||
|
||||
@@ -14,6 +14,10 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
// reuse the r/g/b-only channel tolerance.
|
||||
const SHADOW_ALPHA_TOLERANCE = 0.02;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
||||
@@ -474,6 +478,25 @@ function addSidecarRadii(out, sidecar) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sidecar `extensions.shadows` entries ({ name, value, purpose }) carry the
|
||||
// documented shadow vocabulary that Stitch's frontmatter schema can't hold.
|
||||
// Their colors go into a separate allowlist — NOT allowedColorKeys — because a
|
||||
// shadow black is only documented *as a shadow*: feeding it into the general
|
||||
// color allowlist would legalize #000 as a page ground (alpha is dropped from
|
||||
// colorKey), which is the hole issue #547 warns against.
|
||||
function addSidecarShadows(out, sidecar) {
|
||||
const shadows = sidecar?.extensions?.shadows;
|
||||
if (!Array.isArray(shadows)) return;
|
||||
|
||||
for (const entry of shadows) {
|
||||
if (typeof entry?.value !== 'string') continue;
|
||||
for (const match of entry.value.matchAll(CSS_COLOR_RE)) {
|
||||
const parsed = parseDesignColor(match[0]);
|
||||
if (parsed) out.allowedShadowColors.push({ color: parsed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDesignSystem(input = {}) {
|
||||
const frontmatter = input.frontmatter || {};
|
||||
const sidecar = input.sidecar || null;
|
||||
@@ -486,6 +509,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
allowedColorKeys: new Map(),
|
||||
allowedRadii: [],
|
||||
allowedFontSizes: [],
|
||||
allowedShadowColors: [],
|
||||
hasPillRadius: false,
|
||||
};
|
||||
|
||||
@@ -495,6 +519,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
addSidecarColors(out, sidecar);
|
||||
addRoundedScale(out, frontmatter.rounded);
|
||||
addSidecarRadii(out, sidecar);
|
||||
addSidecarShadows(out, sidecar);
|
||||
|
||||
out.hasFonts = out.allowedFonts.size > 0;
|
||||
out.hasColors = out.allowedColorKeys.size > 0;
|
||||
@@ -614,6 +639,20 @@ function isAllowedColorRaw(raw, designSystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A color is a documented shadow color only when both the r/g/b channels AND
|
||||
// the alpha match a sidecar shadow token's color. Alpha has to be compared
|
||||
// here because colorKey()/colorsClose() drop it, and a match on r/g/b alone
|
||||
// would let every black at every alpha through.
|
||||
function isAllowedShadowColorRaw(raw, designSystem) {
|
||||
if (!designSystem?.allowedShadowColors?.length) return false;
|
||||
const parsed = parseDesignColor(String(raw || '').trim().toLowerCase());
|
||||
if (!parsed) return false;
|
||||
return designSystem.allowedShadowColors.some(entry =>
|
||||
colorsClose(parsed, entry.color) &&
|
||||
Math.abs((parsed.a ?? 1) - (entry.color.a ?? 1)) <= SHADOW_ALPHA_TOLERANCE,
|
||||
);
|
||||
}
|
||||
|
||||
function isAllowedRadiusRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasRadii) return true;
|
||||
const text = String(raw || '').trim().toLowerCase();
|
||||
@@ -691,6 +730,40 @@ function isProbablyColorLiteral(line, match) {
|
||||
return styleContext || cssFunctionContext || jsColorKeyContext;
|
||||
}
|
||||
|
||||
// One complete `${...}` template interpolation. Its content may carry paired
|
||||
// quoted strings (function arguments, ternary branches) and one level of
|
||||
// braces (an object-literal argument, itself allowing paired quotes). Deeper
|
||||
// nesting would need a parser, so the regex deliberately fails safe there:
|
||||
// the context check misses and the finding fires — a false positive a waiver
|
||||
// can silence, never a leak.
|
||||
const QUOTED_STRING_SRC = `"[^"]*"|'[^']*'`;
|
||||
const INTERPOLATION_SRC =
|
||||
`\\$\\{(?:${QUOTED_STRING_SRC}|\\{(?:${QUOTED_STRING_SRC}|[^{}"'\`])*\\}|[^{}"'\`])*\\}`;
|
||||
// The two shadow-context tails. Unlike jsColorKeyContext, the JS tail admits
|
||||
// commas: a multi-layer shadow string is comma-separated, and a later
|
||||
// property on the same line is still blocked because it sits past the
|
||||
// string's closing quote. Both tails admit complete interpolations; a bare
|
||||
// `}`, quote, or `;` still ends the context.
|
||||
const SHADOW_CSS_CONTEXT_RE = new RegExp(
|
||||
`(?:^|[{\\s;"'\`(,])(?:box-shadow|text-shadow)\\s*:\\s*(?:${INTERPOLATION_SRC}|[^;{}"'\`])*$`, 'i',
|
||||
);
|
||||
const SHADOW_JS_CONTEXT_RE = new RegExp(
|
||||
`(?:^|[,{]\\s*)(?:boxShadow|textShadow)\\s*[:=]\\s*["'\`]?(?:${INTERPOLATION_SRC}|[^"'\`}])*$`, 'i',
|
||||
);
|
||||
|
||||
// True when the color literal sits inside a box-shadow / text-shadow value —
|
||||
// the only contexts where a documented shadow color is legal. Anchored to the
|
||||
// end of `before` (no ; } { or quote in between) so a shadow property earlier
|
||||
// on the line can't leak the allowance into a later declaration. Kept separate
|
||||
// from isProbablyColorLiteral(), which stays a boolean for its existing call
|
||||
// sites and deliberately discards which property matched.
|
||||
function isShadowPropertyContext(line, match) {
|
||||
const index = match.index ?? -1;
|
||||
if (index < 0) return false;
|
||||
const before = line.slice(0, index);
|
||||
return SHADOW_CSS_CONTEXT_RE.test(before) || SHADOW_JS_CONTEXT_RE.test(before);
|
||||
}
|
||||
|
||||
function isInsideCssAttributeSelector(line, index) {
|
||||
if (index < 0) return false;
|
||||
const before = line.slice(0, index);
|
||||
@@ -824,6 +897,7 @@ function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
if (!isProbablyColorLiteral(line, match)) continue;
|
||||
const raw = cssColorLabel(match[0]);
|
||||
if (isAllowedColorRaw(raw, designSystem)) continue;
|
||||
if (isShadowPropertyContext(line, match) && isAllowedShadowColorRaw(raw, designSystem)) continue;
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-color',
|
||||
filePath,
|
||||
@@ -1038,6 +1112,7 @@ export {
|
||||
loadDesignSystemForCwd,
|
||||
isAllowedFont,
|
||||
isAllowedColorRaw,
|
||||
isAllowedShadowColorRaw,
|
||||
isAllowedRadiusRaw,
|
||||
isAllowedFontSizeRaw,
|
||||
checkSourceDesignSystem,
|
||||
|
||||
@@ -425,25 +425,28 @@ const REGEX_MATCHERS = [
|
||||
},
|
||||
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
|
||||
// --- Layout property transition ---
|
||||
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
|
||||
// JSX inline style objects use comma-delimited quoted values, not semicolons (issue #548).
|
||||
{ id: 'layout-transition', regex: /transition\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi,
|
||||
test: (m) => {
|
||||
const val = m[1].toLowerCase();
|
||||
const val = (m[2] ?? m[3] ?? '').toLowerCase();
|
||||
if (/\ball\b/.test(val)) return false;
|
||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||
},
|
||||
fmt: (m) => {
|
||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
|
||||
const raw = m[2] ?? m[3] ?? '';
|
||||
const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition: ${found ? found.join(', ') : raw.trim()}`;
|
||||
} },
|
||||
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
|
||||
{ id: 'layout-transition', regex: /transition-property\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi,
|
||||
test: (m) => {
|
||||
const val = m[1].toLowerCase();
|
||||
const val = (m[2] ?? m[3] ?? '').toLowerCase();
|
||||
if (/\ball\b/.test(val)) return false;
|
||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||
},
|
||||
fmt: (m) => {
|
||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
|
||||
const raw = m[2] ?? m[3] ?? '';
|
||||
const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition-property: ${found ? found.join(', ') : raw.trim()}`;
|
||||
} },
|
||||
// --- Broken image: src="" or src="#" or src=" " ---
|
||||
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkBuildPathUnset,
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
checkNativePlatformEvidence,
|
||||
@@ -120,6 +121,7 @@ async function collect(cwd, targetOptions) {
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...checkHookInstallation({
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
*
|
||||
* node generate-image.mjs --prompt "..." --out mock.png [--size 1536x1024] [--quality medium]
|
||||
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
|
||||
* node generate-image.mjs --prompt "..." --out mock.png --ref screenshot.png [--ref more.png]
|
||||
*
|
||||
* --ref anchors generation on input image(s) via the edits endpoint: pass a
|
||||
* captured screenshot of a representative existing page when comping a new
|
||||
* surface for an established world, so the identity comes from the real UI.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import zlib from 'node:zlib';
|
||||
@@ -212,12 +217,44 @@ if (!prompt || !out) {
|
||||
}
|
||||
const size = arg('size', '1536x1024');
|
||||
const quality = arg('quality', 'medium');
|
||||
// Reference images (--ref, repeatable): route through the edits endpoint,
|
||||
// which accepts input images. This is how a comp for an established world
|
||||
// inherits the real UI's identity from a captured screenshot instead of a
|
||||
// prose paraphrase of it; the prompt then describes the NEW surface and the
|
||||
// reference carries palette, type, and component character.
|
||||
const refs = (() => {
|
||||
const found = [];
|
||||
for (let i = 0; i < process.argv.length; i += 1) {
|
||||
if (process.argv[i] === '--ref' && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) found.push(process.argv[i + 1]);
|
||||
}
|
||||
return found;
|
||||
})();
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/images/generations', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
|
||||
});
|
||||
let response;
|
||||
if (refs.length) {
|
||||
const form = new FormData();
|
||||
form.append('model', 'gpt-image-2');
|
||||
form.append('prompt', prompt);
|
||||
form.append('size', size);
|
||||
form.append('quality', quality);
|
||||
form.append('n', '1');
|
||||
for (const ref of refs) {
|
||||
const bytes = fs.readFileSync(ref);
|
||||
const type = ref.endsWith('.png') ? 'image/png' : ref.endsWith('.webp') ? 'image/webp' : 'image/jpeg';
|
||||
form.append('image[]', new Blob([bytes], { type }), ref.split('/').pop());
|
||||
}
|
||||
response = await fetch('https://api.openai.com/v1/images/edits', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
body: form,
|
||||
});
|
||||
} else {
|
||||
response = await fetch('https://api.openai.com/v1/images/generations', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error(`generate-image: API error ${response.status}: ${(await response.text()).slice(0, 300)}`);
|
||||
process.exit(1);
|
||||
@@ -235,6 +272,6 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
|
||||
try {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2' }, null, 2));
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
|
||||
} catch { /* embedding is best-effort */ }
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
||||
|
||||
@@ -47,18 +47,33 @@ import {
|
||||
|
||||
// Top-level keys any reader honors: `hook` and `detector` subtrees (hook-lib's
|
||||
// readConfig), `updateCheck` (context.mjs), `projectRoots` (context.mjs's
|
||||
// monorepo resolution), plus `stalenessCheck` below. `$schema` and `version`
|
||||
// are allowed as conventional metadata nobody reads.
|
||||
// monorepo resolution), `buildPath` (context.mjs's build-path directive), plus
|
||||
// `stalenessCheck` below. `$schema` and `version` are allowed as conventional
|
||||
// metadata nobody reads.
|
||||
const KNOWN_CONFIG_KEYS = new Set([
|
||||
'hook',
|
||||
'detector',
|
||||
'updateCheck',
|
||||
'stalenessCheck',
|
||||
'projectRoots',
|
||||
'buildPath',
|
||||
'$schema',
|
||||
'version',
|
||||
]);
|
||||
|
||||
// The only two values context.mjs and new-work honor. A near miss reads as a
|
||||
// working preference and silently rides the opposite path, so it is worth
|
||||
// reporting rather than coercing.
|
||||
const BUILD_PATH_VALUES = Object.freeze(['comp', 'code']);
|
||||
|
||||
// Evidence that this project does the kind of work `buildPath` governs. A
|
||||
// project that only ever ran polish or audit has no use for the setting and
|
||||
// should never be told it exists. Two stats, so Tier 1 can afford it.
|
||||
const DIRECTION_WORK_PATHS = Object.freeze([
|
||||
path.join('.impeccable', 'surfaces'),
|
||||
path.join('.impeccable', 'mocks', 'decision'),
|
||||
]);
|
||||
|
||||
// `detector` is a closed set, so a typo here is worth reporting. `hook` is not
|
||||
// checked: it carries runtime settings from several writers and the false
|
||||
// positive rate would outweigh the catch.
|
||||
@@ -325,6 +340,20 @@ export function checkConfig({ projectRoot, repoRoot }) {
|
||||
}));
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'buildPath')
|
||||
&& !BUILD_PATH_VALUES.includes(raw.buildPath)) {
|
||||
findings.push(finding({
|
||||
id: 'config-invalid-build-path',
|
||||
artifact: 'config.json',
|
||||
filePath: rel,
|
||||
severity: 'mention',
|
||||
summary: `${rel} sets \`buildPath\` to ${JSON.stringify(raw.buildPath)}, which nothing reads. `
|
||||
+ `The values are ${BUILD_PATH_VALUES.map((value) => `\`${value}\``).join(' and ')}.`,
|
||||
fix: 'Report the value. An unread `buildPath` does not fall back to the other path; '
|
||||
+ 'it falls back to the default, so a project meaning `code` has been building comp-led.',
|
||||
}));
|
||||
}
|
||||
|
||||
const detector = raw.detector;
|
||||
if (detector && typeof detector === 'object' && !Array.isArray(detector)) {
|
||||
const unknownDetector = Object.keys(detector).filter((key) => !KNOWN_DETECTOR_KEYS.has(key));
|
||||
@@ -345,6 +374,47 @@ export function checkConfig({ projectRoot, repoRoot }) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* No recorded build-path preference on a project that plainly does visual
|
||||
* direction work. Not drift in the usual sense: the setting is newer than the
|
||||
* project, so every project that predates it lands here at once. That is why
|
||||
* it is gated twice, on a product record and on evidence of the work the
|
||||
* setting governs, and why it says the choice rather than assuming a harness
|
||||
* can make it. Image generation is the real precondition and this module
|
||||
* cannot see it: a harness-native image tool leaves no trace on disk, so the
|
||||
* finding hands the question to the one reader that knows.
|
||||
*/
|
||||
export function checkBuildPathUnset({ projectRoot, repoRoot, product }) {
|
||||
if (!projectRoot || !product) return [];
|
||||
const roots = [...new Set([projectRoot, repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
||||
|
||||
for (const root of roots) {
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = readJson(path.join(root, '.impeccable', name));
|
||||
// Any declared value ends this, valid or not: an invalid one already has
|
||||
// its own finding and two reports of one key is noise.
|
||||
if (raw && Object.prototype.hasOwnProperty.call(raw, 'buildPath')) return [];
|
||||
}
|
||||
}
|
||||
|
||||
const evidence = DIRECTION_WORK_PATHS.filter((rel) => fs.existsSync(path.join(projectRoot, rel)));
|
||||
if (!evidence.length) return [];
|
||||
|
||||
return [finding({
|
||||
id: 'config-build-path-unset',
|
||||
artifact: 'config.json',
|
||||
filePath: '.impeccable/config.json',
|
||||
severity: 'mention',
|
||||
summary: 'This project has run visual direction work but records no `buildPath`, '
|
||||
+ 'so every direction round takes the comp-first default without anyone having chosen it.',
|
||||
fix: 'Only when image generation exists in your tool surface, offer the choice once: '
|
||||
+ '**comp-first** (an image sets the bar before any code; bolder composition, slower) or '
|
||||
+ '**code-first** (build directly; ambition carried by the direction contract; leaner, faster). '
|
||||
+ 'Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, '
|
||||
+ 'merging with the keys already there. Without image generation there is no choice to record: stay silent.',
|
||||
})];
|
||||
}
|
||||
|
||||
// ─── Surface briefs ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -446,6 +516,7 @@ export function collectBootFindings(ctx, extras = {}) {
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
|
||||
@@ -170,51 +170,35 @@ Output (JSON):
|
||||
}
|
||||
|
||||
if (svelteComponentManifest) {
|
||||
if (isDiscard) {
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
||||
'discard:' + id,
|
||||
() => {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
return { handled: true };
|
||||
},
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err);
|
||||
}
|
||||
emitResult({
|
||||
...result,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
carbonize: false,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
||||
'accept:' + id,
|
||||
() => inlineSvelteComponentAccept(
|
||||
const { sourceFile, componentDir } = svelteComponentManifest;
|
||||
const resultContext = {
|
||||
file: sourceFile,
|
||||
...(isDiscard ? { carbonize: false } : { sourceFile }),
|
||||
previewMode: 'svelte-component',
|
||||
componentDir,
|
||||
};
|
||||
const runOperation = isDiscard
|
||||
? () => {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
return { handled: true, ...resultContext };
|
||||
}
|
||||
: () => inlineSvelteComponentAccept(
|
||||
svelteComponentManifest,
|
||||
variantNum,
|
||||
paramValues,
|
||||
process.cwd(),
|
||||
),
|
||||
);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), sourceFile),
|
||||
requestedOperation + ':' + id,
|
||||
runOperation,
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err, {
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
sourceFile: svelteComponentManifest.sourceFile,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
});
|
||||
result = operationFailure(err, resultContext);
|
||||
}
|
||||
if (result.carbonize) {
|
||||
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
|
||||
|
||||
@@ -22,6 +22,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
// All known harness directories
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
|
||||
'.hermes',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
||||
];
|
||||
|
||||
|
||||
@@ -42,14 +42,14 @@
|
||||
* // raise lines under the identity row
|
||||
* "risk": "one line: the honest risk", // optional
|
||||
* "body": "fallback prose when the structured fields are absent",
|
||||
* "sketch": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
|
||||
* // full-fidelity direction comp (the field
|
||||
* // keeps the sketch era's wire name). May not
|
||||
* // exist yet: the page shimmer-waits and
|
||||
* "comp": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
|
||||
* // full-fidelity direction comp (the legacy
|
||||
* // key "sketch" is accepted as an alias). May
|
||||
* // not exist yet: the page shimmer-waits and
|
||||
* // polls the slot until the file lands, so
|
||||
* // serve first and generate after
|
||||
* "hero": "https://... or /abs/path.webp", // optional inspiration image;
|
||||
* // rides picture-in-picture when a sketch exists
|
||||
* // rides picture-in-picture when a comp exists
|
||||
* "board": "https://... or /abs/path.webp" // optional secondary image
|
||||
* }, ...
|
||||
* ],
|
||||
@@ -61,7 +61,7 @@
|
||||
* "canon": true, // adds the "Play it straight" standing exit;
|
||||
* // direction rounds only (returns {"optionId":"canon"})
|
||||
* "canonCard": { ... }, // optional: the standing exit as a full card with the
|
||||
* // same anatomy (label, thesis, palette, sketch, ...);
|
||||
* // same anatomy (label, thesis, palette, comp, ...);
|
||||
* // rendered last and visually subordinate. Without it,
|
||||
* // canon stays a quiet footer action.
|
||||
* "steer": true, // adds a free-text steer field returned with any answer
|
||||
@@ -75,7 +75,7 @@
|
||||
* // then the execution contract.
|
||||
* }
|
||||
*
|
||||
* Options render as large cards: the sketch leads when present, with the
|
||||
* Options render as large cards: the comp leads when present, with the
|
||||
* inspiration image picture-in-picture; a hero alone renders full-bleed; a
|
||||
* text-only direction gets its identity from the palette chips and tags.
|
||||
* Local image paths are served by this server; nothing is uploaded anywhere.
|
||||
@@ -148,7 +148,7 @@ function printAnswer(raw) {
|
||||
if (a.hero || a.board) {
|
||||
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code. When your harness only reads files, or runs sandboxed, download them INTO the workspace and open the relative path; a sandboxed viewer rejects absolute paths outside it. They set the craft bar the build must reach.");
|
||||
}
|
||||
if (a.sketch) {
|
||||
if (a.comp) {
|
||||
console.log('CHOSEN COMP: the decision comp at that path is compositional option one. On a comp-led build the comp round adds two variations beside it; on a code-led build it returns at the finish review as the critique reference. Never regenerate it from scratch.');
|
||||
}
|
||||
if (a.optionId === 'canon') {
|
||||
@@ -160,6 +160,18 @@ function printAnswer(raw) {
|
||||
if (a.followup && a.optionId !== 'reroll') {
|
||||
console.log('FOLLOWUP OPEN: the table stays open and the page is showing a loading hand. Deliver the next round now with --update --key <key> --payload <file>, then collect it with --wait; never leave the page waiting on a round you have not sent.');
|
||||
}
|
||||
if (a.buildPath === 'comp' || a.buildPath === 'code') {
|
||||
// The page never writes the flip itself, but "never write it" overstated
|
||||
// that into a rule the agent then applied to new-work's one-time offer,
|
||||
// which exists for exactly this case: a flip on a project that had no
|
||||
// recorded default is the only moment the preference is ever asked for.
|
||||
const origin = a.buildPathFlipped
|
||||
? 'flipped on the page, so it binds this session only, and the page never writes it back; the sole exception is new-work’s one-time offer, on a project that had no recorded default at all, which asks after the round closes and writes the answer to .impeccable/config.json'
|
||||
: 'the round’s recorded default';
|
||||
console.log(`BUILD PATH: ${a.buildPath} (${origin}). ${a.buildPath === 'comp'
|
||||
? 'Comp-led: the chosen card’s comp is law; generate it before building when it does not exist yet, and the finish review audits the build against it.'
|
||||
: 'Code-led: no comp is owed; a comp that already rendered rides at the finish review as the critique reference, and the ambition lives in the direction contract.'}`);
|
||||
}
|
||||
} catch { /* raw answer */ }
|
||||
}
|
||||
|
||||
@@ -169,23 +181,29 @@ const portArg = Number(arg('port', '0'));
|
||||
const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions');
|
||||
const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`);
|
||||
const answerFile = (key) => path.join(QUESTION_DIR, `${key}.answer.json`);
|
||||
// A code-to-comp flip mid-round: the page records it here and --wait
|
||||
// surfaces it as its own event, because the agent must start generating
|
||||
// comps while the round is still open. Comp-to-code needs no event; it is
|
||||
// free and rides the final ANSWER.
|
||||
const flipFile = (key) => path.join(QUESTION_DIR, `${key}.flip.json`);
|
||||
|
||||
if (hasFlag('schema')) {
|
||||
console.log(JSON.stringify({
|
||||
title: 'Choose the visual world',
|
||||
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
|
||||
options: [
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], sketch: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
|
||||
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'MY PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as today’s ballad.', risk: 'Also the direction most runs in this category land on.', sketch: '.impeccable/mocks/decision/model-pick.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', sketch: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], comp: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
|
||||
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'IMPECCABLE’S PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as today’s ballad.', risk: 'Also the direction most runs in this category land on.', comp: '.impeccable/mocks/decision/model-pick.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', comp: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'challenger-microfiche', label: 'Microfiche Reader', verdict: 'declined', lineage: 'library microfiche stations', palette: ['#101418', '#9fb4c0'], materials: ['film grain', 'backlit glass'], case: 'Fuses poorly: listeners do not identify with archival retrieval.', kept: 'Total environmental commitment.', hero: 'https://impeccable.style/worlds/cards/archives-microfiche-reader-hero.webp' },
|
||||
],
|
||||
reroll: { registers: ['safer', 'bolder'] },
|
||||
buildPath: { value: 'comp', toggle: true },
|
||||
canon: true,
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', sketch: '.impeccable/mocks/decision/canon.webp' },
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', comp: '.impeccable/mocks/decision/canon.webp' },
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no sketch (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "MY PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. The sketch slot carries the card’s full-fidelity direction comp (the field keeps its wire name for compatibility). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. followup: true keeps the table open after a pick for a second round via --update (direction first, then the execution contract); send the next payload immediately, the page is waiting on it.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/comp accept URLs or local paths; comp slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the comp, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no comp (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "IMPECCABLE’S PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. A card may instead declare "wireframe" ({"cols":12,"rows":10,"regions":[{"label":"nav rail","x":0,"y":0,"w":3,"h":10,"accent":true}]}): the page draws it as a layout schematic in the media slot; surface-scope rounds use it on code-led builds, it never counts toward salience, and the card keeps its full read on the front. The comp slot carries the card’s full-fidelity direction comp (the legacy key "sketch" is accepted as an alias). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. buildPath rides the payload as { "value": "comp"|"code", "toggle": true }: the value is the recorded default (.impeccable/config.json buildPath, or .impeccable/config.local.json where one machine differs) and the toggle renders a footer switch whose flip binds that session only; the ANSWER then carries buildPath plus buildPathFlipped. On a code-led round each card still declares its comp path as a flip reserve: wireframes render, and a flip to comp makes --wait return once with BUILD PATH FLIPPED so you generate the comps into the declared slots while the round stays open; a flip back to code is free, and a comp that already landed stays as the critique reference. The toggle may only be offered when image generation exists: a harness with no image tool and no API key never sets toggle: true, so the choice never renders where comps cannot be made, and code-led simply rides as the untoggleable value. followup: true keeps the table open after a pick for a second round via --update; send the next payload immediately, the page is waiting on it.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -212,6 +230,13 @@ if (hasFlag('wait')) {
|
||||
let sawClose = false;
|
||||
while (Date.now() < deadline) {
|
||||
if (answered()) break;
|
||||
// A build-path flip is its own event, not an answer: the round stays
|
||||
// open, and the agent's job right now is comps, not code.
|
||||
if (fs.existsSync(flipFile(key))) {
|
||||
try { fs.rmSync(flipFile(key)); } catch { /* consumed elsewhere */ }
|
||||
console.log('BUILD PATH FLIPPED: comp (for this session only; never write it to settings). The table is still open and the page shows shimmer where the images will land: generate each open card’s comp into its declared path now, lead first, then collect the answer with --wait again. A card whose comp already exists needs nothing.');
|
||||
process.exit(0);
|
||||
}
|
||||
if (!alive()) {
|
||||
console.log('serve-question: the question server is gone with no answer. This is a server failure, not a user decision: restart it with --start and the same payload, reopen the URL for the user, and wait again. Never proceed without their choice while their browser session is open.');
|
||||
process.exit(2);
|
||||
@@ -307,6 +332,12 @@ else raw = fs.readFileSync(0, 'utf8');
|
||||
let payload;
|
||||
let options;
|
||||
let localImages = [];
|
||||
// Build path (comp-led vs code-led): the payload carries the recorded
|
||||
// default; the page's toggle updates the live value per session. The server
|
||||
// owns both so the final ANSWER states the path and whether it was flipped
|
||||
// even when the round never rendered a toggle.
|
||||
let buildPathDefault = null;
|
||||
let liveBuildPath = null;
|
||||
|
||||
function loadRound(json) {
|
||||
const parsed = JSON.parse(json);
|
||||
@@ -322,10 +353,10 @@ function loadRound(json) {
|
||||
localImages.push(abs);
|
||||
return `/img/${localImages.length - 1}`;
|
||||
};
|
||||
// Sketches stream in after the page is served, so their slots register
|
||||
// Comps stream in after the page is served, so their slots register
|
||||
// whether or not the file exists yet; /img answers 404 until it lands and
|
||||
// the page polls the slot. Remote sketch URLs pass through untouched.
|
||||
const sketchSrc = (value) => {
|
||||
// the page polls the slot. Remote comp URLs pass through untouched.
|
||||
const compSrc = (value) => {
|
||||
if (!value) return null;
|
||||
if (/^https?:\/\//.test(value)) return value;
|
||||
localImages.push(path.resolve(value));
|
||||
@@ -336,7 +367,7 @@ function loadRound(json) {
|
||||
...option,
|
||||
heroSrc: imageSrc(option.hero),
|
||||
boardSrc: imageSrc(option.board),
|
||||
sketchSrc: sketchSrc(option.sketch),
|
||||
compSrc: compSrc(option.comp ?? option.sketch),
|
||||
});
|
||||
options = parsed.options.map(decorate);
|
||||
// The verdict routes rendering: full cards first, then the canon, then the
|
||||
@@ -352,6 +383,10 @@ function loadRound(json) {
|
||||
options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
|
||||
}
|
||||
options = [...options, ...declined];
|
||||
buildPathDefault = (parsed.buildPath && (parsed.buildPath.value === 'comp' || parsed.buildPath.value === 'code'))
|
||||
? { value: parsed.buildPath.value, toggle: parsed.buildPath.toggle === true }
|
||||
: null;
|
||||
liveBuildPath = buildPathDefault?.value ?? null;
|
||||
}
|
||||
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
|
||||
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
|
||||
@@ -368,18 +403,25 @@ function page() {
|
||||
// no generation luck can distort.
|
||||
const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
|
||||
const demoted = (option) => option.verdict === 'declined';
|
||||
// The build path (comp-led vs code-led) is a workflow preference, not a
|
||||
// design decision: the payload carries the recorded default and whether
|
||||
// the page offers the toggle. On a code-led round a declared comp path is
|
||||
// a flip reserve, not a face: wireframes render, and the slot only starts
|
||||
// shimmering when the user flips to comp.
|
||||
const buildPath = buildPathDefault;
|
||||
const codeLed = buildPath?.value === 'code';
|
||||
// Salience parity: a card's imagery weight is capped by the assigned card's.
|
||||
// When the lead card has no media at all (no image generation this round,
|
||||
// and no catalog art of its own), full-bleed catalog art beside a text-only
|
||||
// assigned card would let rendering luck outvote the weighing: users click
|
||||
// the colorful thing. Declined cards are thumb-only regardless; the verdict
|
||||
// demoted them, and a full-bleed hero would promote them right back.
|
||||
const identityRound = !(options[0] && (options[0].sketchSrc || options[0].heroSrc || options[0].boardSrc));
|
||||
// A declined card never renders a full media face, sketch included: even a
|
||||
// declared sketch would buy back the salience the verdict took away.
|
||||
const faceSketch = (option) => demoted(option) ? null : option.sketchSrc;
|
||||
const thumbOnly = (option) => !faceSketch(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
|
||||
const hasMedia = (option) => Boolean(faceSketch(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
|
||||
const identityRound = !(options[0] && (options[0].compSrc || options[0].heroSrc || options[0].boardSrc));
|
||||
// A declined card never renders a full media face, comp included: even a
|
||||
// declared comp would buy back the salience the verdict took away.
|
||||
const faceComp = (option) => (demoted(option) || codeLed) ? null : option.compSrc;
|
||||
const thumbOnly = (option) => !faceComp(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
|
||||
const hasMedia = (option) => Boolean(faceComp(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
|
||||
// The back exists to keep long facts off a card whose front is an image;
|
||||
// a card with no art has no flip chip to reach it, so it gets no back and
|
||||
// the full read lives on the front instead.
|
||||
@@ -402,15 +444,16 @@ function page() {
|
||||
// generous hand cannot blow the card out of proportion.
|
||||
if (Array.isArray(option.raised) && option.raised.length) {
|
||||
const nameOf = (id) => options.find((o) => o.id === id)?.label || String(id ?? '');
|
||||
const raiseLines = option.raised.slice(0, 6).map((r) => `<p class="raise"><span class="fact-label">Raised by ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
|
||||
const raiseLines = option.raised.slice(0, 6).map((r) => `<p class="raise"><span class="fact-label">From ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
|
||||
const raisesHead = (count) => `<div class="raises-head"><span class="fact-label">Improved by Impeccable's worlds</span>${count > 1 ? `<span class="raises-count" data-raises-count>1/${count}</span>` : ''}</div>`;
|
||||
if (raiseLines.length > 1) {
|
||||
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter to see the next raise" aria-label="Raised by the hand; activate to see the next raise">
|
||||
<div class="raises-head"><span class="fact-label">Raised by the hand</span><span class="raises-count" data-raises-count>1/${raiseLines.length}</span></div>
|
||||
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter for the next improvement" aria-label="How Impeccable's worlds improved this direction; activate to see the next improvement">
|
||||
${raisesHead(raiseLines.length)}
|
||||
${raiseLines.join('')}
|
||||
<span class="sr-live" aria-live="polite"></span>
|
||||
</div>`);
|
||||
} else {
|
||||
rows.push(`<div class="raises">${raiseLines[0]}</div>`);
|
||||
rows.push(`<div class="raises">${raisesHead(1)}${raiseLines[0]}</div>`);
|
||||
}
|
||||
}
|
||||
// Demoted art stays reachable as a labeled thumb: the catalog world
|
||||
@@ -421,7 +464,7 @@ function page() {
|
||||
}
|
||||
// The front carries only what the choice needs: thesis, identity, and the
|
||||
// honest risk clamped to two lines. First viewport and the case read on
|
||||
// the card's back; once the sketch lands, the first viewport is a picture.
|
||||
// the card's back; once the comp lands, the first viewport is a picture.
|
||||
// With no art there is no back, so the full read fills the room the
|
||||
// image would have taken.
|
||||
if (hasMedia(option)) {
|
||||
@@ -444,24 +487,27 @@ function page() {
|
||||
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
|
||||
].filter(Boolean).join('\n ');
|
||||
const media = (option) => {
|
||||
const inspiration = option.heroSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(option.heroSrc)}" alt="">
|
||||
const inspirationSrc = option.heroSrc || option.boardSrc;
|
||||
const inspiration = inspirationSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(inspirationSrc)}" alt="">
|
||||
<figcaption>inspiration</figcaption>
|
||||
</figure>` : '';
|
||||
const details = hasBack(option) ? flipChip('Details') : '';
|
||||
// Thumb-only art renders inside the body via anatomy(), never as a face,
|
||||
// and a declined card's sketch slot is ignored outright.
|
||||
// and a declined card's comp slot is ignored outright.
|
||||
if (thumbOnly(option)) return '';
|
||||
if (faceSketch(option)) {
|
||||
return `<div class="media sketching" data-sketch="${esc(option.sketchSrc)}">
|
||||
<div class="shimmer"><span class="sketch-note">rendering…</span></div>
|
||||
<img class="sketch" alt="" hidden>
|
||||
if (faceComp(option)) {
|
||||
const textOnlyFacts = backFacts(option);
|
||||
return `<div class="media comp-pending" data-comp="${esc(option.compSrc)}">
|
||||
<div class="shimmer"><span class="comp-note">rendering…</span></div>
|
||||
<img class="comp" alt="" hidden>
|
||||
${inspiration}
|
||||
<template class="text-only-facts">${textOnlyFacts}</template>
|
||||
<div class="chips">${expandChip}${details}</div>
|
||||
</div>`;
|
||||
}
|
||||
if (option.heroSrc || option.boardSrc) {
|
||||
// Without a sketch the catalog art is the card's face; it stays a
|
||||
// Without a comp the catalog art is the card's face; it stays a
|
||||
// labeled reference so it never reads as the promise of the build.
|
||||
return `<div class="media" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||
@@ -471,13 +517,36 @@ function page() {
|
||||
}
|
||||
return '';
|
||||
};
|
||||
// Wireframe media: a code-led card's layout schematic, authored as grid
|
||||
// regions in the payload and drawn by the page; boxes and labels, no art.
|
||||
// It fills the media slot only when the card has no imagery, and it never
|
||||
// counts toward salience or earns a card back: the full read stays on the
|
||||
// front, exactly like a text-only card.
|
||||
const wire = (option) => {
|
||||
const frame = option.wireframe;
|
||||
if (!frame || !Array.isArray(frame.regions) || !frame.regions.length || media(option) || demoted(option)) return '';
|
||||
const cols = Number(frame.cols) > 0 ? Number(frame.cols) : 12;
|
||||
const rows = Number(frame.rows) > 0 ? Number(frame.rows) : 10;
|
||||
const pct = (n, total) => `${Math.max(0, Math.min(100, (n / total) * 100)).toFixed(2)}%`;
|
||||
const cells = frame.regions.slice(0, 12).map((region) => {
|
||||
const x = Number(region.x) || 0;
|
||||
const y = Number(region.y) || 0;
|
||||
const w = Math.max(Number(region.w) || 1, 0.5);
|
||||
const h = Math.max(Number(region.h) || 1, 0.5);
|
||||
return `<div class="wire-region${region.accent ? ' accent' : ''}" style="left:${pct(x, cols)};top:${pct(y, rows)};width:${pct(w, cols)};height:${pct(h, rows)}"><span>${esc(region.label || '')}</span></div>`;
|
||||
}).join('');
|
||||
return `<div class="media wire" role="img" aria-label="Layout schematic">
|
||||
<div class="wire-field">${cells}</div>
|
||||
<p class="media-label">layout</p>
|
||||
</div>`;
|
||||
};
|
||||
const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this';
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}"${codeLed && option.compSrc && !demoted(option) ? ` data-comp-slot="${esc(option.compSrc)}"` : ''}>
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${(media(option) || wire(option)) ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : demoted(option) ? '<span class="kicker declined-k">Declined</span>' : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
|
||||
${media(option)}
|
||||
${media(option) || wire(option)}
|
||||
<div class="body">
|
||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||
<h2>${esc(option.label)}</h2>
|
||||
@@ -526,9 +595,12 @@ function page() {
|
||||
--ks-font-display: "Alumni Sans", "Albert Sans", Arial, sans-serif;
|
||||
--ks-font: "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, system-ui, sans-serif;
|
||||
--ks-mono: "SFMono-Regular", "Roboto Mono", "JetBrains Mono", Consolas, monospace;
|
||||
/* One inset shared by the content column, the deck's snap padding, and
|
||||
the sticky footer, so all three align on the same 90rem column. */
|
||||
--page-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2));
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 2rem; min-height: 100dvh; display: flex; flex-direction: column; overflow-x: clip; }
|
||||
body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 0; min-height: 100dvh; display: flex; flex-direction: column; overflow-x: clip; }
|
||||
#ambient { position: fixed; inset: -40px; z-index: 0; background-size: cover; background-position: center; filter: blur(34px) saturate(1.05); opacity: 0; transition: opacity .55s ease, background-image .2s; pointer-events: none; }
|
||||
#scrim { position: fixed; inset: 0; z-index: 0; background: linear-gradient(180deg, oklch(7% 0.006 95 / 0.62), oklch(7% 0.006 95 / 0.78)); pointer-events: none; }
|
||||
header, main, footer { position: relative; z-index: 1; }
|
||||
@@ -540,7 +612,7 @@ function page() {
|
||||
.brand { display: flex; align-items: center; gap: .55rem; color: var(--ks-kinpaku); }
|
||||
.brand svg { width: 22px; height: 22px; }
|
||||
.wordmark { font-family: var(--ks-font-display); font-weight: 400; font-size: 1.125rem; letter-spacing: 0.15em; text-transform: uppercase; line-height: 1; color: var(--ks-kinpaku); }
|
||||
.headline { display: flex; align-items: center; gap: .9rem; }
|
||||
.headline { display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; }
|
||||
.headline-die { flex: none; width: 34px; height: 34px; color: var(--ks-kinpaku); }
|
||||
h1 { font-family: var(--ks-font-display); font-weight: 100; font-size: clamp(2.6rem, 5vw, 4.2rem); letter-spacing: -0.01em; line-height: 1.02; color: var(--ks-champagne); }
|
||||
.question { color: var(--ks-text-muted); margin-top: .7rem; max-width: 52rem; }
|
||||
@@ -552,12 +624,24 @@ function page() {
|
||||
.deck-shell { position: relative; width: 100vw; margin-left: calc(50% - 50vw); }
|
||||
/* One row in a wide viewport, one column in a tall one; the deck scrolls on
|
||||
its axis with snap points and the arrows page it card by card. */
|
||||
.grid { --deck-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2)); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; }
|
||||
.grid { --deck-inset: var(--page-inset); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; }
|
||||
.grid::-webkit-scrollbar { display: none; }
|
||||
/* Wide enough that the sketch carries the card: at 27vw the imagery read
|
||||
/* Wide enough that the comp carries the card: at 27vw the imagery read
|
||||
as a thumbnail above a column of copy, and the copy won the attention
|
||||
contest the sketch is supposed to win. */
|
||||
contest the comp is supposed to win. */
|
||||
.grid > .card { flex: 0 0 clamp(24rem, 34vw, 34rem); scroll-snap-align: center; }
|
||||
/* Short landscape viewports (13-inch laptops): header, a 34vw card, and the
|
||||
footer do not fit 800px of height, so the headline compacts and the deck
|
||||
narrows. Height is the axis that gives; the sticky footer keeps the
|
||||
round's verbs on screen while a too-tall card scrolls. */
|
||||
@media (min-aspect-ratio: 1/1) and (max-height: 900px) {
|
||||
body { padding-top: 1.1rem; }
|
||||
h1 { font-size: clamp(2rem, 3.4vw, 2.9rem); }
|
||||
.question { margin-top: .45rem; }
|
||||
.stage { gap: 1rem; }
|
||||
.grid > .card { flex-basis: clamp(20rem, 27vw, 27rem); }
|
||||
.grid > .card.declined { flex-basis: clamp(13rem, 18vw, 18rem); }
|
||||
}
|
||||
.nav { position: absolute; z-index: 6; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: oklch(7% 0.006 95 / 0.78); border: 1px solid var(--ks-rule); color: var(--ks-kinpaku); cursor: pointer; backdrop-filter: blur(6px); transition: border-color .2s, color .2s, opacity .2s; }
|
||||
.nav:hover { border-color: var(--ks-kinpaku-deep); color: var(--ks-kinpaku-pale); }
|
||||
.nav[disabled] { opacity: .25; cursor: default; }
|
||||
@@ -588,6 +672,10 @@ function page() {
|
||||
shrink a declined card to content WIDTH, not height, so it stretches
|
||||
like every other card and its height is already its own. */
|
||||
.grid > .card.declined { align-self: stretch; }
|
||||
/* The sticky bar is a wide-viewport fix. Here it would sit over the
|
||||
deck's More pager and cost a third of a phone screen, and the deck
|
||||
already scrolls internally, so the footer stays in the page flow. */
|
||||
footer { position: static; width: auto; margin: 1rem 0 0; padding: .7rem 0 1.2rem; background: transparent; border-top: 0; backdrop-filter: none; }
|
||||
}
|
||||
.card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
|
||||
.card:hover { transform: rotate(0deg) translateY(-4px); }
|
||||
@@ -611,7 +699,7 @@ function page() {
|
||||
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; }
|
||||
/* 16/10 matches the landscape sketch frame; portrait art overrides the
|
||||
/* 16/10 matches the landscape comp frame; portrait art overrides the
|
||||
slot with its own exact ratio at load (see the load listener), and the
|
||||
deck narrows so portrait cards line up side by side. */
|
||||
.media { position: relative; width: 100%; aspect-ratio: 16/10; flex: none; }
|
||||
@@ -647,14 +735,14 @@ function page() {
|
||||
.body.back-body { overflow-y: auto; flex: 1; scrollbar-width: thin; }
|
||||
/* Inspiration rides picture-in-picture: the catalog world explains where the
|
||||
direction comes from without promising what the build will look like. */
|
||||
/* Hovering the inspiration takes over the whole media region; the sketch is
|
||||
/* Hovering the inspiration takes over the whole media region; the comp is
|
||||
the promise, the inspiration is a glance, so the glance must cost nothing. */
|
||||
.pip { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; width: 84px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; background: var(--ks-lacquer); cursor: zoom-in; transition: left .35s cubic-bezier(.16,1,.3,1), bottom .35s cubic-bezier(.16,1,.3,1), width .35s cubic-bezier(.16,1,.3,1), height .35s cubic-bezier(.16,1,.3,1), border-radius .35s ease; box-shadow: 0 6px 18px oklch(0% 0 0 / 0.45); }
|
||||
.pip img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 3px 0 4px; background: oklch(7% 0.006 95 / 0.72); backdrop-filter: blur(3px); }
|
||||
.pip:hover { left: 0; bottom: 0; width: 100%; height: 100%; border-radius: 0; z-index: 3; }
|
||||
.sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||
/* Catalog art standing in for a sketchless card is a reference, and says so
|
||||
.comp-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||
/* Catalog art standing in for a comp-less card is a reference, and says so
|
||||
on its face; the same pill later carries "artwork unavailable". */
|
||||
.media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); padding: 3px 8px 4px; background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 4px; backdrop-filter: blur(3px); }
|
||||
/* Art that never arrives collapses to the card's own palette (painted
|
||||
@@ -666,15 +754,15 @@ function page() {
|
||||
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
|
||||
.media.unavailable .chips { z-index: 2; }
|
||||
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
||||
the real sketch whenever it lands. */
|
||||
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
||||
the real comp whenever it lands. */
|
||||
.media.stand-in img.comp { filter: brightness(.72) saturate(.85); }
|
||||
.media.stand-in .pip { display: none; }
|
||||
.stand-in-label { position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; margin: 0; font-family: var(--ks-mono); font-size: .56rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 4px 0 5px; background: oklch(7% 0.006 95 / 0.78); backdrop-filter: blur(3px); }
|
||||
.media.sketching { position: relative; }
|
||||
.media.sketching .shimmer { position: absolute; inset: 0; }
|
||||
.media img.sketch { position: relative; z-index: 1; }
|
||||
.media.comp-pending { position: relative; }
|
||||
.media.comp-pending .shimmer { position: absolute; inset: 0; }
|
||||
.media img.comp { position: relative; z-index: 1; }
|
||||
/* The generic .media img display:block would defeat [hidden] and float an
|
||||
empty block over the shimmer; an unloaded sketch must truly not render. */
|
||||
empty block over the shimmer; an unloaded comp must truly not render. */
|
||||
.media img[hidden] { display: none; }
|
||||
/* Declined challengers: the weighing demoted them, so the card is narrower
|
||||
and quieter, its catalog art rides as a labeled thumb in the body, and
|
||||
@@ -690,22 +778,33 @@ function page() {
|
||||
.kicker.declined-k { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); }
|
||||
.card.declined button.choose { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font-size: .85rem; padding: 8px 22px; }
|
||||
.card.declined button.choose:hover { background: var(--ks-graphite-2); border-color: var(--ks-text-muted); }
|
||||
/* Wireframe media: the code-led schematic. Quiet boxes in the card's own
|
||||
chrome; uniform salience across cards by construction, so it needs no
|
||||
parity rules. */
|
||||
.media.wire { background: var(--ks-lacquer); border-bottom: 1px solid var(--ks-rule); }
|
||||
.wire-field { position: absolute; inset: 12px 12px 26px; }
|
||||
.wire-region { position: absolute; border: 1px solid oklch(78% 0 0 / 0.26); border-radius: 3px; background: oklch(78% 0 0 / 0.05); display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
||||
.wire-region span { font-family: var(--ks-mono); font-size: .55rem; letter-spacing: .1em; text-transform: uppercase; color: var(--ks-text-faint); text-align: center; padding: 2px 4px; }
|
||||
.wire-region.accent { border-color: oklch(84% 0.19 80.46 / 0.5); background: oklch(84% 0.19 80.46 / 0.06); }
|
||||
.wire-region.accent span { color: var(--ks-kinpaku-rich); }
|
||||
/* Thumb-scale inspiration: present, labeled, zoomable, and incapable of
|
||||
outshouting a text-only assigned card. */
|
||||
.inspo { position: relative; flex: none; margin: 2px 0; width: 104px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; cursor: zoom-in; background: var(--ks-lacquer); }
|
||||
.inspo img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.inspo figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .48rem; letter-spacing: .16em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 2px 0 3px; background: oklch(7% 0.006 95 / 0.72); }
|
||||
/* Raises: the donations the assigned direction took from the hand it beat,
|
||||
each named for its donor. Patina, not kinpaku: a raise is provenance. */
|
||||
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; }
|
||||
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; border-left: 2px solid var(--ks-patina); padding-left: 8px; }
|
||||
/* Raises: the improvements the dealt worlds donated to the assigned
|
||||
direction, each named for its donor world. Patina, not kinpaku:
|
||||
provenance, not a call to action. A quiet contained panel, never an
|
||||
accent side-tab. */
|
||||
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; padding: 7px 10px 8px; background: oklch(70% 0.12 188 / 0.06); border: 1px solid oklch(70% 0.12 188 / 0.22); border-radius: 8px; }
|
||||
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; }
|
||||
.raise .fact-label { color: var(--ks-patina); }
|
||||
/* Several raises cycle instead of stacking: one visible at a time, a
|
||||
/* Several kept ideas cycle instead of stacking: one visible at a time, a
|
||||
counter for the rest, the whole block advances on click. */
|
||||
.raises-cycle { cursor: pointer; border-radius: 6px; }
|
||||
.raises-cycle .raise { display: none; border-left: none; padding-left: 0; }
|
||||
.raises-cycle { cursor: pointer; transition: border-color .2s ease; }
|
||||
.raises-cycle:hover { border-color: oklch(70% 0.12 188 / 0.45); }
|
||||
.raises-cycle .raise { display: none; }
|
||||
.raises-cycle .raise.active { display: block; }
|
||||
.raises-cycle { border-left: 2px solid var(--ks-patina); padding-left: 8px; }
|
||||
.raises-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.raises-head .fact-label { color: var(--ks-patina); }
|
||||
.raises-count { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .14em; color: var(--ks-text-faint); }
|
||||
@@ -721,9 +820,39 @@ function page() {
|
||||
.card.canon button.choose:hover { border-color: var(--ks-text-muted); background: var(--ks-graphite-2); }
|
||||
button.choose { margin-top: auto; align-self: start; background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font-family: var(--ks-font); font-size: 1rem; font-weight: 500; line-height: 1.35; padding: 10px 38px; border-radius: 6px; cursor: pointer; transition: background .15s; }
|
||||
button.choose:hover { background: var(--ks-kinpaku-pale); }
|
||||
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
||||
/* The round's verbs stay reachable on short viewports: the footer is a
|
||||
full-bleed bar stuck to the viewport bottom and the deck scrolls under
|
||||
it. Same inset as the content column, so the controls stay aligned. */
|
||||
footer { position: sticky; bottom: 0; z-index: 10; width: 100vw; margin: 1.2rem calc(50% - 50vw) 0; padding: .7rem var(--page-inset) calc(.7rem + env(safe-area-inset-bottom, 0px)); display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: oklch(7% 0.006 95 / 0.82); backdrop-filter: blur(10px); border-top: 1px solid var(--ks-rule); }
|
||||
#steer { flex: 1; min-width: 16rem; background: var(--ks-lacquer-raised); color: var(--ks-text); border: 1px solid var(--ks-rule); border-radius: 7px; padding: .6rem .85rem; font: inherit; }
|
||||
#steer:focus { outline: none; border-color: var(--ks-patina); }
|
||||
/* Build-path toggle: a workflow preference surfaced as a quiet segmented
|
||||
control on the headline row, right-aligned opposite the title, its trade stated in
|
||||
one line that changes with the selection. The default comes from the
|
||||
payload (settings); flipping binds this session only, and the agent
|
||||
learns about a code-to-comp flip live. Rendered only when the payload
|
||||
offers it, which the agent does only when image generation exists. */
|
||||
#build-path { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; flex: none; margin-left: auto; }
|
||||
.bp-switch { display: inline-flex; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; }
|
||||
.bp-note { text-align: right; }
|
||||
.bp-opt { font-family: var(--ks-mono); font-size: .62rem; letter-spacing: .12em; text-transform: uppercase; padding: 7px 12px; background: transparent; border: 0; color: var(--ks-text-faint); cursor: pointer; transition: color .2s ease, background-color .2s ease; }
|
||||
.bp-opt + .bp-opt { border-left: 1px solid var(--ks-rule); }
|
||||
.bp-opt.active { color: var(--ks-dark-ink); background: var(--ks-kinpaku-rich); }
|
||||
.bp-opt:not(.active):hover { color: var(--ks-text); }
|
||||
.bp-note { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .04em; color: var(--ks-text-faint); max-width: 21rem; line-height: 1.5; }
|
||||
/* Flipping to comp starts billed, minutes-long generation, so it asks
|
||||
first; flipping back is free and never does. */
|
||||
#bp-confirm { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; background: oklch(4% 0.004 95 / 0.72); opacity: 0; transition: opacity .2s ease; }
|
||||
#bp-confirm[hidden] { display: none; }
|
||||
#bp-confirm.open { opacity: 1; }
|
||||
.bp-confirm-panel { max-width: 26rem; margin: 1rem; background: var(--ks-lacquer-raised); border: 1px solid var(--ks-rule); border-radius: 10px; padding: 1.4rem 1.5rem 1.3rem; box-shadow: 0 30px 80px oklch(0% 0 0 / 0.55); }
|
||||
.bp-confirm-panel h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; color: var(--ks-champagne); margin-bottom: .55rem; }
|
||||
.bp-confirm-panel p { font-size: .875rem; line-height: 1.55; color: var(--ks-text-muted); }
|
||||
.bp-confirm-actions { display: flex; gap: .6rem; margin-top: 1.1rem; }
|
||||
.bp-confirm-go { background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font: inherit; font-weight: 500; padding: 9px 22px; border-radius: 6px; cursor: pointer; }
|
||||
.bp-confirm-go:hover { background: var(--ks-kinpaku-pale); }
|
||||
.bp-confirm-stay { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font: inherit; padding: 9px 18px; border-radius: 6px; cursor: pointer; }
|
||||
.bp-confirm-stay:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
|
||||
.reroll-btn { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
.reroll-btn:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
.reroll-btn svg { width: 15px; height: 15px; }
|
||||
@@ -752,6 +881,16 @@ function page() {
|
||||
<div id="ambient" aria-hidden="true"></div>
|
||||
<div id="scrim" aria-hidden="true"></div>
|
||||
<div id="lightbox" hidden><img alt=""></div>
|
||||
${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria-labelledby="bp-confirm-title" hidden>
|
||||
<div class="bp-confirm-panel">
|
||||
<h2 id="bp-confirm-title">Flip to comp-first?</h2>
|
||||
<p>The agent starts rendering a comp for every open card right away, about a minute or two per card on your image provider, and the images land on the cards as they finish. This flip binds this session only.</p>
|
||||
<div class="bp-confirm-actions">
|
||||
<button type="button" class="bp-confirm-go" data-confirm>Render comps</button>
|
||||
<button type="button" class="bp-confirm-stay" data-cancel>Keep code-first</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<header>
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>
|
||||
@@ -763,6 +902,13 @@ function page() {
|
||||
<div class="headline">
|
||||
<svg class="headline-die" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>
|
||||
<h1>${esc(payload.title || 'Choose a direction')}</h1>
|
||||
${buildPath?.toggle ? `<div id="build-path" data-default="${buildPath.value}">
|
||||
<div class="bp-switch" role="radiogroup" aria-label="Build path">
|
||||
<button type="button" class="bp-opt" data-bp="comp" role="radio" aria-checked="false">Comp first</button>
|
||||
<button type="button" class="bp-opt" data-bp="code" role="radio" aria-checked="false">Code first</button>
|
||||
</div>
|
||||
<p class="bp-note" data-bp-note></p>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
${payload.question ? `<p class="question">${esc(payload.question)}</p>` : ''}
|
||||
<div class="deck-shell">
|
||||
@@ -822,7 +968,7 @@ function page() {
|
||||
if (count) count.textContent = (at + 1) + '/' + raises.length;
|
||||
// Screen readers hear the raise they just advanced to; the initial
|
||||
// render stays quiet so page load does not narrate every card.
|
||||
if (announce && live) live.textContent = 'Raise ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');
|
||||
if (announce && live) live.textContent = 'Improvement ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');
|
||||
};
|
||||
show(false);
|
||||
const advance = (e) => { e.stopPropagation(); at = (at + 1) % raises.length; show(true); };
|
||||
@@ -863,46 +1009,147 @@ function page() {
|
||||
}));
|
||||
}
|
||||
|
||||
// Sketches stream in after the deal: poll each slot until the file lands,
|
||||
// Comps stream in after the deal: poll each slot until the file lands,
|
||||
// then swap the shimmer for the image. Generation is genuinely slow and a
|
||||
// sequential batch puts the last card many minutes out, so patience is the
|
||||
// default: a slot only shows its inspiration as a stand-in when it has
|
||||
// waited four minutes AND nothing has landed anywhere for four minutes, the
|
||||
// stand-in is labeled as such, and polling continues so the real sketch
|
||||
// stand-in is labeled as such, and polling continues so the real comp
|
||||
// still swaps in whenever it arrives. Progress anywhere resets patience.
|
||||
const landTracker = { last: Date.now() };
|
||||
document.querySelectorAll('.media.sketching').forEach(m => {
|
||||
const url = m.dataset.sketch;
|
||||
const img = m.querySelector('img.sketch');
|
||||
const note = m.querySelector('.sketch-note');
|
||||
const pollComp = (m) => {
|
||||
const url = m.dataset.comp;
|
||||
const img = m.querySelector('img.comp');
|
||||
const note = m.querySelector('.comp-note');
|
||||
const started = Date.now();
|
||||
// A live elapsed count is the difference between "working" and "frozen".
|
||||
const tick = setInterval(() => { if (note) note.textContent = 'rendering · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
|
||||
const settle = () => { clearInterval(tick); m.classList.remove('sketching', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
|
||||
const standIn = () => {
|
||||
const settle = () => { clearInterval(tick); m.classList.remove('comp-pending', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
|
||||
const fallback = () => {
|
||||
const pip = m.querySelector('.pip img');
|
||||
if (!pip || m.classList.contains('stand-in')) return;
|
||||
img.src = pip.getAttribute('src'); img.hidden = false;
|
||||
m.classList.add('stand-in');
|
||||
m.querySelector('.shimmer')?.remove();
|
||||
clearInterval(tick);
|
||||
const label = document.createElement('p');
|
||||
label.className = 'stand-in-label';
|
||||
label.textContent = 'inspiration · comp pending';
|
||||
m.appendChild(label);
|
||||
if (pip) {
|
||||
if (m.classList.contains('stand-in')) return false;
|
||||
img.src = pip.getAttribute('src'); img.hidden = false;
|
||||
m.classList.add('stand-in');
|
||||
m.querySelector('.shimmer')?.remove();
|
||||
clearInterval(tick);
|
||||
const label = document.createElement('p');
|
||||
label.className = 'stand-in-label';
|
||||
label.textContent = 'inspiration · comp pending';
|
||||
m.appendChild(label);
|
||||
return false;
|
||||
}
|
||||
|
||||
// No comp and no inspiration is the text-only card the payload would
|
||||
// have rendered without a comp declaration. Bring the complete read
|
||||
// forward before removing the now-unreachable back face.
|
||||
const card = m.closest('.card');
|
||||
const front = card?.querySelector('.face.front');
|
||||
const body = front?.querySelector('.body');
|
||||
const back = card?.querySelector('.face.back');
|
||||
const textOnlyFacts = m.querySelector('template.text-only-facts');
|
||||
const choose = body?.querySelector(':scope > button.choose');
|
||||
if (body && textOnlyFacts && choose) {
|
||||
const plainDetail = body.querySelector(':scope > .detail:not(.more)');
|
||||
[...body.children].filter((el) => el.classList.contains('fact') || el.matches('.detail.more')).forEach((el) => el.remove());
|
||||
choose.before(textOnlyFacts.content.cloneNode(true));
|
||||
if (plainDetail) choose.before(plainDetail);
|
||||
}
|
||||
card?.classList.remove('flipped');
|
||||
front?.classList.add('text-only');
|
||||
back?.remove();
|
||||
settle();
|
||||
m.remove();
|
||||
return true;
|
||||
};
|
||||
const tryLoad = () => {
|
||||
// A slot the user flipped back out of leaves the DOM; let its loop die.
|
||||
if (!m.isConnected) { clearInterval(tick); return; }
|
||||
const probe = new Image();
|
||||
probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); };
|
||||
probe.onerror = () => {
|
||||
const quiet = Date.now() - landTracker.last > 240000;
|
||||
if (Date.now() - started > 240000 && quiet) standIn();
|
||||
if (Date.now() - started > 240000 && quiet && fallback()) return;
|
||||
setTimeout(tryLoad, m.classList.contains('stand-in') ? 5000 : 2500);
|
||||
};
|
||||
probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||
};
|
||||
tryLoad();
|
||||
});
|
||||
};
|
||||
document.querySelectorAll('.media.comp-pending').forEach(pollComp);
|
||||
|
||||
// Build-path toggle: the default is the round's recorded preference and
|
||||
// flipping binds this session only. Flipping code to comp swaps every
|
||||
// reserve slot (data-comp-slot) to its shimmer and tells the server, so
|
||||
// the waiting agent starts generating; flipping back is free: pending
|
||||
// slots return to their wireframes, a comp that already landed stays.
|
||||
const bp = document.getElementById('build-path');
|
||||
if (bp) {
|
||||
const notes = {
|
||||
comp: 'An image sets the bar first and the build must match it. Bolder composition; comps render before code.',
|
||||
code: 'Code builds directly; the ambition is written into the contract and audited at the finish. Leaner, faster.',
|
||||
};
|
||||
const noteEl = bp.querySelector('[data-bp-note]');
|
||||
let current = bp.dataset.default;
|
||||
const set = (value) => {
|
||||
current = value;
|
||||
bp.querySelectorAll('.bp-opt').forEach(b => {
|
||||
const on = b.dataset.bp === value;
|
||||
b.classList.toggle('active', on);
|
||||
b.setAttribute('aria-checked', String(on));
|
||||
});
|
||||
if (noteEl) noteEl.textContent = notes[value];
|
||||
};
|
||||
set(current);
|
||||
const enterComp = () => {
|
||||
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
|
||||
const front = card.querySelector('.face.front');
|
||||
if (!front || front.querySelector('.media.comp-pending') || front.querySelector('.media img.comp:not([hidden])')) return;
|
||||
const m = document.createElement('div');
|
||||
m.className = 'media comp-pending';
|
||||
m.dataset.comp = card.dataset.compSlot;
|
||||
m.innerHTML = '<div class="shimmer"><span class="comp-note">rendering…</span></div><img class="comp" alt="" hidden>';
|
||||
const wireEl = front.querySelector('.media.wire');
|
||||
if (wireEl) { wireEl.hidden = true; front.insertBefore(m, wireEl); }
|
||||
else { front.classList.remove('text-only'); front.insertBefore(m, front.querySelector('.body')); }
|
||||
pollComp(m);
|
||||
});
|
||||
};
|
||||
const exitComp = () => {
|
||||
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
|
||||
const front = card.querySelector('.face.front');
|
||||
const pending = front?.querySelector('.media.comp-pending');
|
||||
if (!pending) return; // landed comps stay; they exist either way
|
||||
pending.remove();
|
||||
const wireEl = front.querySelector('.media.wire');
|
||||
if (wireEl) wireEl.hidden = false;
|
||||
else if (!front.querySelector('.media')) front.classList.add('text-only');
|
||||
});
|
||||
};
|
||||
const apply = (value) => {
|
||||
set(value);
|
||||
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
|
||||
if (value === 'comp') enterComp(); else exitComp();
|
||||
};
|
||||
// Flipping to comp starts real generation, so it confirms first; the
|
||||
// flip back is free and applies immediately.
|
||||
const confirm = document.getElementById('bp-confirm');
|
||||
const closeConfirm = () => { confirm.classList.remove('open'); confirm.hidden = true; };
|
||||
confirm.querySelector('[data-confirm]').addEventListener('click', () => { closeConfirm(); apply('comp'); });
|
||||
confirm.querySelector('[data-cancel]').addEventListener('click', closeConfirm);
|
||||
confirm.addEventListener('click', (e) => { if (e.target === confirm) closeConfirm(); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !confirm.hidden) closeConfirm(); });
|
||||
bp.querySelectorAll('.bp-opt').forEach(b => b.addEventListener('click', () => {
|
||||
const value = b.dataset.bp;
|
||||
if (value === current) return;
|
||||
if (value === 'comp') {
|
||||
confirm.hidden = false;
|
||||
requestAnimationFrame(() => confirm.classList.add('open'));
|
||||
return;
|
||||
}
|
||||
apply(value);
|
||||
}));
|
||||
}
|
||||
|
||||
// A declared image that never loads (missing catalog asset, offline shell)
|
||||
// must not sit as a dark void: the slot collapses to the card's own
|
||||
@@ -910,7 +1157,7 @@ function page() {
|
||||
// slots are excluded; their polling owns the wait.
|
||||
const artFailed = (img) => {
|
||||
const m = img.closest('.media');
|
||||
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
|
||||
if (!m || m.classList.contains('comp-pending') || m.classList.contains('unavailable')) return;
|
||||
m.classList.add('unavailable');
|
||||
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
|
||||
if (colors.length) m.style.background = 'linear-gradient(135deg, ' + colors.map((c, i) => c + ' ' + Math.round(i * 100 / colors.length) + '% ' + Math.round((i + 1) * 100 / colors.length) + '%').join(', ') + ')';
|
||||
@@ -923,7 +1170,7 @@ function page() {
|
||||
label.textContent = 'artwork unavailable';
|
||||
m.appendChild(label);
|
||||
};
|
||||
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
|
||||
document.querySelectorAll('.media:not(.comp-pending) > img').forEach(img => {
|
||||
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
||||
else img.addEventListener('error', () => artFailed(img), { once: true });
|
||||
});
|
||||
@@ -1109,6 +1356,26 @@ const server = http.createServer((req, res) => {
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/build-path') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end('{"ok":true}');
|
||||
let value = null;
|
||||
try { value = JSON.parse(body).value; } catch { /* ignore */ }
|
||||
if (value !== 'comp' && value !== 'code') return;
|
||||
const wasComp = liveBuildPath === 'comp';
|
||||
liveBuildPath = value;
|
||||
// Only a flip TO comp needs the agent mid-round: comps must start
|
||||
// rendering into the declared slots. The reverse is free.
|
||||
if (detachedKey && value === 'comp' && !wasComp) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/answer') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
@@ -1129,7 +1396,8 @@ const server = http.createServer((req, res) => {
|
||||
...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}),
|
||||
...(followupOpen ? { followup: true } : {}),
|
||||
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||
...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
|
||||
...((chosen?.comp ?? chosen?.sketch) ? { comp: chosen.comp ?? chosen.sketch } : {}),
|
||||
...(liveBuildPath && !isReroll ? { buildPath: liveBuildPath, buildPathFlipped: liveBuildPath !== (buildPathDefault?.value ?? null) } : {}),
|
||||
});
|
||||
if (detachedKey) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
|
||||
@@ -16,7 +16,7 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh
|
||||
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ An open direction round owns the word first: "bolder" said while a direction dec
|
||||
|
||||
## Scope is sovereign
|
||||
|
||||
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, stop and ask the user directly to clarify what you cannot infer. before expanding it, naming the exact addition and the job it would do.
|
||||
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, do not expand it on your own. Ask the user directly to clarify what you cannot infer. Name the exact addition and the job it would do.
|
||||
|
||||
## Why it reads flat
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ Resolve one stable target, run two independent assessments, synthesize a design
|
||||
- Viewable targets require browser inspection when available.
|
||||
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
|
||||
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
|
||||
- The question is the LAST thing in the response. Write the entire report out first, then ask; nothing follows the question. Prose emitted after a structured question is withheld until the user answers it, so a report written after the question reads as if the critique never ran.
|
||||
- A run that ends with neither the targeted questions nor a literal `Questions skipped: <reason>` line is an incomplete run. The report is not the finish; the close is.
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -172,6 +174,14 @@ Provocative questions that might unlock better solutions:
|
||||
- Prioritize ruthlessly. If everything is important, nothing is.
|
||||
- Don't soften criticism. Developers need honest feedback to ship great design.
|
||||
|
||||
### Deliver the Report
|
||||
|
||||
Write the full report into the chat response now, before any persistence work. This is the deliverable; everything below it is bookkeeping.
|
||||
|
||||
Do this first because the alternative is the most common way this command fails: the report gets composed once, straight into the persistence heredoc, and the run ends with a perfect archive nobody has read. Composing it into a file is not delivering it. If the report exists only in `.impeccable/critique/`, the run produced nothing.
|
||||
|
||||
Persistence is not the end of the run. After it, the response continues with the trend line and the close.
|
||||
|
||||
### Persist the Snapshot
|
||||
|
||||
Once the report above is finalized, write it to `.impeccable/critique/` so the user can refer back, and so `/impeccable polish` can pick up the priority issues without a copy-paste.
|
||||
@@ -180,6 +190,8 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
|
||||
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, design-specificity verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
|
||||
|
||||
This is a copy of the report you already delivered above, for later commands to read. It is not delivery. If you find yourself composing the report for the first time inside this heredoc, you have skipped Deliver the Report; go back and send it.
|
||||
|
||||
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
|
||||
```bash
|
||||
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
|
||||
@@ -204,11 +216,15 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
|
||||
If this is the first run for the slug, the trend is just one score; say so: "First run for this target, no trend yet."
|
||||
|
||||
6. **Close the run.** Go to Ask the User below and emit the questions, or the `Questions skipped: <reason>` line when the count allows it. The run is not complete until you do. Persistence is bookkeeping and cleanup is not an ending; stopping here leaves the user with a report and no way forward, and leaves `/impeccable polish` with no priorities to inherit.
|
||||
|
||||
This is fire-and-forget. Do not show the user the helper's JSON output; only the human-readable trend line and the written path. Failures here should not block the rest of the flow; print the error and move on.
|
||||
|
||||
### Ask the User
|
||||
|
||||
**After presenting findings**, use targeted questions based on what was actually found. ask the user directly to clarify what you cannot infer. These answers will shape the action plan.
|
||||
**After presenting findings**, use targeted questions based on what was actually found. Ask the user directly to clarify what you cannot infer. These answers will shape the action plan.
|
||||
|
||||
Ask in the same message that carries the report, with the report written out first and the question last. Do not split the two across turns: a turn that ends on the report is a turn that ends, and the questions never arrive. Order within the message is what matters, because prose emitted after a structured question is withheld until the user answers.
|
||||
|
||||
Ask questions along these lines (adapt to the specific findings; do NOT ask generic questions):
|
||||
|
||||
@@ -224,7 +240,9 @@ Ask questions along these lines (adapt to the specific findings; do NOT ask gene
|
||||
- Every question must reference specific findings from the report. Never ask generic "who is your audience?" questions.
|
||||
- Keep it to 2-4 questions maximum. Respect the user's time.
|
||||
- Offer concrete options, not open-ended prompts.
|
||||
- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Recommended Actions.
|
||||
- Skipping is allowed only when the report listed **fewer than 3 Priority Issues**. Count them; do not judge the findings "straightforward" by feel. At 3 or more, the questions are required.
|
||||
|
||||
**Final-question gate.** The user-visible response must either include the targeted questions or carry the literal line `Questions skipped: <reason>` naming the count that permitted the skip. Each question must include 2-3 concrete answer options tied to the actual critique findings. Do not end with only open-ended questions, and do not end with neither: stopping after the report, having asked nothing and printed no skip line, is the most common way this command fails.
|
||||
|
||||
### Recommended Actions
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh
|
||||
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Analyze what makes the design feel complex or cluttered:
|
||||
- What can be removed, hidden, or combined?
|
||||
- What's the 20% that delivers 80% of value?
|
||||
|
||||
If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
|
||||
If any of these are unclear from the codebase, do not guess. Ask the user directly to clarify what you cannot infer.
|
||||
|
||||
**CRITICAL**: Simplicity is not about removing features. It's about removing obstacles between users and their goals. Every element should justify its existence.
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ The same restraint applies to `workspace-context-inherited`. Inheritance is a de
|
||||
|
||||
- `workspace-platform-native-evidence` is the finding that matters most here: a workspace carrying native build files while inheriting a root record that resolves to web gets web guidance for its whole life and never loads [ios.md](ios.md) or [android.md](android.md). The repair is a child PRODUCT.md in that workspace, because one inherited record cannot hold two platforms.
|
||||
- `config-project-roots-match-nothing` means every `projectRoots` glob missed, so the repo root is silently standing in as the active project. A renamed workspace directory is the usual cause. Report the patterns and ask which directories they should name.
|
||||
- `config-invalid-build-path` and `config-build-path-unset` both concern one key, `buildPath` in `.impeccable/config.json` (or the gitignored `.impeccable/config.local.json`, which wins for that developer). It holds `comp` or `code` and sets whether new surfaces are built from a generated comp or straight in code. An unread value does not fall back to the opposite path, so a project meaning `code` has been building comp-led; report the exact value. The unset finding fires only where a project has done direction work and never recorded a preference, and the offer belongs in it only when image generation exists in your tool surface. Without image generation there is nothing to choose and nothing to say.
|
||||
- Use the `workspaces` table to show the user which apps carry their own context, which inherit, and which have none, before proposing any change.
|
||||
|
||||
## Opting out of the boot check
|
||||
|
||||
@@ -68,7 +68,7 @@ Omit irrelevant sections rather than filling them with invented rules. Put respo
|
||||
- An existing `DESIGN.md` is stale (the design has drifted).
|
||||
- Before a large redesign, to capture the current state as a reference.
|
||||
|
||||
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file and ask the user directly to clarify what you cannot infer. whether to refresh, overwrite, or merge.
|
||||
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file first. Ask the user directly to clarify what you cannot infer. The choice is refresh, overwrite, or merge.
|
||||
|
||||
## Two paths
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Identify reusable patterns, components, and design tokens, then extract and cons
|
||||
|
||||
Find the design system, component library, or shared UI directory. Understand its structure: component organization, naming conventions, design token structure, import/export conventions.
|
||||
|
||||
**CRITICAL**: If no design system exists, ask the user directly to clarify what you cannot infer. before creating one. Understand the preferred location and structure first.
|
||||
**CRITICAL**: If no design system exists, do not create one yet. Ask the user directly to clarify what you cannot infer. Understand the preferred location and structure first.
|
||||
|
||||
## Step 2: Identify Patterns
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Form a platform hypothesis: `web`, `ios`, `android`, or `adaptive` (one product
|
||||
|
||||
## Step 3: Interview for product truth
|
||||
|
||||
ask the user directly to clarify what you cannot infer. Ask only about material gaps the repository and original request do not answer with strong evidence.
|
||||
Ask the user directly to clarify what you cannot infer. Ask only about material gaps the repository and original request do not answer with strong evidence.
|
||||
|
||||
Use the structured question tool when available; otherwise ask and wait. Keep rounds to at most three focused questions and require one real answer or approval round before writing a new PRODUCT.md. Confirm inferences.
|
||||
|
||||
@@ -107,9 +107,11 @@ When the platform you just recorded is `ios`, `android`, or `adaptive`, load [io
|
||||
|
||||
Before loading new-work or resuming shape/build, verify that PRODUCT.md exists at the resolved path and contains the confirmed product record. If the file is absent, init is incomplete. Do not substitute interview notes, a planning packet, or later design prose for the file.
|
||||
|
||||
## Step 5: Configure live mode when useful
|
||||
## Step 5: Record workflow defaults
|
||||
|
||||
Skip native or non-runnable projects and leave existing config untouched. Otherwise follow [live.md](live.md)'s first-time setup. Any CSP source edit still requires its stated consent.
|
||||
When image generation is available (context.mjs reports it) and no `buildPath` is recorded yet, ask once how new surfaces should be built, stated as the trade it is: **comp-first** (an image sets the bar before any code; bolder composition, slower, and the build must match the image) or **code-first** (build directly; the ambition is written into the direction contract and audited at the finish; leaner, faster). Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, merging with the keys already there. A value already recorded in `.impeccable/config.json` or the gitignored `.impeccable/config.local.json` is a confirmed answer: on a re-run, honor it in silence rather than asking again. This is a default, not a lock: the decision page renders a toggle whose flip binds a single session and is never written back. Without image generation there is no choice to record; code-first is the only path.
|
||||
|
||||
Then configure live mode when useful: skip native or non-runnable projects and leave existing config untouched. Otherwise follow [live.md](live.md)'s first-time setup. Any CSP source edit still requires its stated consent.
|
||||
|
||||
## Step 6: Wrap up or resume
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Keep the visual system fixed. Derive five to seven materially different structur
|
||||
|
||||
`node .cursor/skills/impeccable/scripts/concept-seed.mjs --scope surface --mode <mode>`
|
||||
|
||||
The script assigns which structure gets built; your top-ranked structure is what every run would ship, so the dice come from outside. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
||||
The script deals three of your structures to the table; the dice decide which three reach the user, so the ranking rut stays broken while the user still holds a real choice. Present the three dealt structures on the decision page as full cards of equal salience, the dealt lead carrying kicker THE ROLL, with steer and re-roll; the user locks one in. No canon card and no pick card at surface scope: the world is settled, so every card visualizes composition, not identity. With image generation available and a comp-led default (the build-path paragraph below: `.impeccable/config.json`, the toggle handles the exception), each card declares a `comp` under `.impeccable/mocks/decision/`, generated after serving in reading order under the comp discipline in [visualize.md](visualize.md); anchor each of these comps on the established identity by passing a captured screenshot of a representative existing page as a reference image (the harness image tool's input image, or `generate-image.mjs --ref`) beside a prompt that leads with the new surface's structure and names DESIGN.md's palette, type, and component character, because a prose paraphrase of a design system drifts where a pixel reference does not. Without image generation, or under a code-led default, each card instead carries a `wireframe` layout schematic (see `serve-question.mjs --schema`) that the page draws itself. Locking a card is the approval and sets the build path: a locked comp builds comp-led with that comp as the approved comp, discharging [visualize.md](visualize.md)'s three-option round with no second approval point; a locked wireframe builds code-led, its ambition carried by the direction contract. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
||||
|
||||
### Create or replace the visual world
|
||||
|
||||
@@ -44,13 +44,13 @@ 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, not only its physical objects: the notation, publications, identity programs, data graphics, and interfaces it reads daily; a nameable abstract system (a school of poster, a documentation standard) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; dig until the list spans at least three families.
|
||||
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
|
||||
4. Run `node .cursor/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point. The weighing closes with a verdict per challenger, decided before any borrowing is considered: wins (beats the assigned direction on both axes; it becomes the build candidate), competitive (holds one axis; it stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a motif lifted from a declined world is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker MY PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
|
||||
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `followup: true` when the execution-contract round will follow (it does whenever image generation exists and no standing build-path preference is recorded); a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .cursor/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (the build-path paragraph below owns the details); a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .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. The fallback to the structured question tool is never yours to predict: run the script, and only exit code 2 from starting it routes the decision there; treat that exit as the fallback, never as an error to retry.
|
||||
|
||||
When image generation exists, every card also declares a `sketch` path under `.impeccable/mocks/decision/` (the field keeps its wire name for compatibility; what it carries is the card's comp), the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished sketch pays sketch quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way; visualize.md's self-checks bind decision comps identically. Generation takes the same time at any fidelity, so an unfinished draft pays draft quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
|
||||
The moment the direction lands, one more round on the same open table decides the execution contract. The direction payload declares `followup: true`, so the table stays open after the pick; deliver the build-path payload through `--update` immediately. Two text-only cards. **Comp-led**: a first-viewport comp is generated and it is law, the finish review audits the build against it; boldest composition on the table, fix rounds expected, motion at risk; choosing it makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. Lead with the chosen world's fit: a costume-heavy catalog world leads comp-led, a quiet or conventional direction leads code-led; the lead is a default, never a decision, and the user flips it freely. A standing preference, voiced once, is recorded as a brand commitment in PRODUCT.md and skips this round on later surfaces. Without image generation there is no fork and no round: code-led is the only path, stated in one line rather than asked. Only a detached table (`--start`) stays open for `--update`: a blocking serve or the structured-tool channel runs the build-path round as its own second question instead, and `followup: true` belongs only on a detached round.
|
||||
The execution contract, comp-led or code-led, is a workflow preference, not a per-surface decision, so no round asks it: the recorded default rides every round and the page's toggle handles the exception. Read the default from `.impeccable/config.json` (`buildPath`), with the gitignored `.impeccable/config.local.json` winning where one machine differs from the team's committed value; with neither, comp-led is the default whenever image generation exists. Author every direction and surface payload with `buildPath: { "value": <default>, "toggle": true }`; the page renders a footer toggle with the trade stated beside it, and the ANSWER returns `buildPath` plus `buildPathFlipped`. A flipped value binds that session only and is never written back, with one exception, and it is the only thing inside a round that earns a question about this preference (init records it up front on projects that get the chance): when `buildPathFlipped` comes back true on a project that records no `buildPath` at all, ask once after the round closes whether to keep it as the standing default. Either answer ends in a write to `.impeccable/config.json`; the answer picks the value, never whether to record one. Yes writes the flipped value, and "no, just this once" writes the value they flipped away from, which is the standing default they just confirmed by declining. Ask on the flip and never on the untouched default, because a user who left the toggle alone has told you nothing. A declined offer nothing writes down is an offer the next session makes again. When the user asks in words to change the standing default, update the file without asking. **Comp-led**: the chosen card's comp is law, generated before building when it does not exist yet, and the finish review audits the build against it; boldest composition on the table, fix rounds expected; comp-led makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. A code-led round still declares each card's comp path as a flip reserve: when the user flips the toggle to comp mid-round, `--wait` returns once with BUILD PATH FLIPPED while the page shimmers the slots; generate each open card's comp into its declared path then, lead first, and wait again. The flip back is free, and a comp that already rendered rides at the finish review as the critique reference. Without image generation there is no toggle and no choice: code-led is the only path, stated in one line rather than asked. The old two-card execution-contract round is retired; `followup: true` remains the general mechanism for delivering any later round over the same table via `--update`.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Push an interface past conventional limits. This isn't just about visual effects
|
||||
This command has the highest potential to misfire. Do NOT jump straight into implementation. You MUST:
|
||||
|
||||
1. **Think through 2-3 different directions**: consider different techniques, levels of ambition, and aesthetic approaches. For each direction, briefly describe what the result would look and feel like.
|
||||
2. **ask the user directly to clarify what you cannot infer.** to present these directions and get the user's pick before writing any code. Explain trade-offs (browser support, performance cost, complexity).
|
||||
2. **Get the user's pick before writing any code.** Ask the user directly to clarify what you cannot infer. Carry each direction's description and its trade-offs (browser support, performance cost, complexity) inside the option itself, so the user is choosing between things they can read. A structured question blocks the message it rides in until the user answers, so directions written alongside the question stay invisible while the user is being asked to choose between them.
|
||||
3. Only proceed with the direction the user confirms.
|
||||
|
||||
Skipping this step risks building something embarrassing that needs to be thrown away.
|
||||
|
||||
@@ -28,7 +28,7 @@ Analyze what makes the design feel too intense:
|
||||
- What's working? (Don't throw away good ideas)
|
||||
- What's the core message? (Preserve what matters)
|
||||
|
||||
If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
|
||||
If any of these are unclear from the codebase, do not guess. Ask the user directly to clarify what you cannot infer.
|
||||
|
||||
**CRITICAL**: "Quieter" doesn't mean boring or generic. It means refined and easier on the eyes. Think luxury, not laziness.
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# Visualize: Direction Comps & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it. A surface-scope structure round that already put three visualized cards before the user (new-work.md, established world) has discharged this round: the locked card’s comp is the approved comp, so record the approval and continue at After approval; generate nothing new.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate three compositional options
|
||||
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here.
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. On an established world, anchor every comp on the real identity: capture a screenshot of a representative existing page and pass it as a reference image (the harness image tool’s input image, or `generate-image.mjs --ref`); the prompt then leads with the new surface’s structure while the reference carries palette, type, and component character, because DESIGN.md words alone drift where a pixel reference does not. Name what the reference contributes and what it must not: chrome, palette, type, and component character carry over; the reference page’s own content does not, so a banner, hero, or card lifted verbatim from the reference is the reference leaking, not fidelity. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here.
|
||||
|
||||
- A comp is a designed surface, not a picture of the subject. Lead the generation prompt with the surface's own structure, whatever regions this design actually has, named in order with their scale relationships; a page with no navigation states that instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with the world's atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph or scene with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally.
|
||||
- The inverse is also a failure: a surface with none of its subject in it. The subject appears as the content the regions exist to hold; the world dresses the frame and never displaces what the frame exists to show. The deletion usually rides in on the prompt's exclusion list, so exclusions bind invented claims, and a medium ban belongs to the committed imagery stance, never to caution. Before accepting a render, point at the subject: a render that depicts everything about the world and nothing of the subject fails however faithful its atmosphere, so regenerate with the subject's content named region by region.
|
||||
- A comp is judged as the shipped screen: the visitor's job must be readable from the image alone. Name the surface's mode from the render with no caption; a render whose mode cannot be read back is art direction without a surface, so regenerate with the visitor's job as the prompt's spine.
|
||||
- Commitment is depth, not coverage. The world enters through one dominant move plus the material, type, and spacing that support it, and the remaining regions hold still so that move can be read; a region that simply does its job in the world's own grammar carries the direction further than a region performing the concept. The check cuts competition, never content: a quieted region keeps its information and stops performing. Where the direction names a focal moment, a second element competing with it at the same scale means the comp is shouting; where it names none, several regions performing the concept at once is the same shout. Regenerate keeping the strongest move and quieting the rest. Busy is louder, not bolder.
|
||||
- When the user shortlisted multiple concepts, spread the three across them.
|
||||
- When 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.
|
||||
@@ -18,7 +21,7 @@ Treat each comp as a direction test, not a screenshot specification. Core UI tex
|
||||
|
||||
## One approval point
|
||||
|
||||
Show the three together: in the harness when it can display images, otherwise on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero). Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
Show the three together on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero), or in the harness only when it renders images inline; a text-only surface does not count as display. Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
|
||||
@@ -338,6 +338,20 @@ export function renderConceptSeed({
|
||||
};
|
||||
const indexSalt = reroll === 0 ? 'index' : `index:reroll-${reroll}`;
|
||||
const buildIndex = 3 + Math.floor(unit(indexSalt) * (candidateCount - 2)); // 3..candidateCount
|
||||
// Surface scope deals a hand of three grounded structures: one card is not
|
||||
// a choice, and the full ranked list would hand selection back to the
|
||||
// model's taste. The dice pick all three; the primary index leads. The
|
||||
// no-lineup rule stays direction-only, where it was written for worlds.
|
||||
const dealtIndices = [buildIndex];
|
||||
for (let draw = 0; scope === 'surface' && dealtIndices.length < Math.min(3, candidateCount); draw += 1) {
|
||||
const idx = 1 + Math.floor(unit(`${indexSalt}:deal-${draw}`) * candidateCount);
|
||||
if (!dealtIndices.includes(idx)) dealtIndices.push(idx);
|
||||
if (draw > 64) { // hash repeats cannot stall the deal
|
||||
for (let fill = 1; dealtIndices.length < Math.min(3, candidateCount); fill += 1) {
|
||||
if (!dealtIndices.includes(fill)) dealtIndices.push(fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local catalog first (private repo, evals, tests), then the roll API,
|
||||
// then a degraded assignment-only seed. The assigned index is pure local
|
||||
@@ -404,19 +418,31 @@ export function renderConceptSeed({
|
||||
interaction and state, and a substantially different future surface. In an
|
||||
attended run, present the assigned direction fully committed and offer
|
||||
re-roll. You may add ONE card for your top-ranked grounded candidate when
|
||||
it is not the assigned direction, kicker MY PICK, with an honest risk line
|
||||
it is not the assigned direction, kicker IMPECCABLE’S PICK, with an honest risk line
|
||||
naming its familiarity; one pick card, never a ranked lineup, and the pick
|
||||
never takes the lead position. When the assignment IS your top candidate,
|
||||
there is no pick card. Re-roll yourself only
|
||||
on named factual grounds, when the assignment cannot carry the product's
|
||||
truth or task; taste is never grounds.`
|
||||
: `After ordering the task's grounded structural candidates by resonance,
|
||||
build candidate ${buildIndex} of your own grounded list; the assignment never
|
||||
points at a challenger. The assignment is the roll, not a suggestion.
|
||||
In an attended run, present the assigned structure and offer re-roll; never
|
||||
present a ranked lineup to choose from. Re-roll yourself only when the
|
||||
assignment fails audience identification or product clarity on named
|
||||
factual grounds.`;
|
||||
deal candidates ${dealtIndices.join(', ')} of your own grounded list to the
|
||||
table; index ${buildIndex} leads, and the deal never points at a challenger.
|
||||
The deal is the roll, not a suggestion: the dice decide which structures
|
||||
reach the user, so the ranking rut stays broken while the user still gets a
|
||||
real choice, and the full ranked list stays yours. In an attended run,
|
||||
present the three dealt structures as full cards of equal salience, the
|
||||
lead carrying kicker THE ROLL, with steer and re-roll, and let the user
|
||||
lock one in; the world is already settled, so this choice is composition.
|
||||
Visualize every dealt card: with image generation available and a
|
||||
comp-led default (.impeccable/config.json buildPath; the page toggle
|
||||
handles the exception), declare a comp per card and generate after
|
||||
serving, lead first; otherwise author each card's wireframe field (see
|
||||
serve-question --schema) and the page draws the schematic. Carry the
|
||||
recorded default in the payload as buildPath with toggle: true. Locking a card
|
||||
approves its comp: a surface round that put three visualized structures on
|
||||
the table replaces the three-option comp round in visualize.md. Re-roll
|
||||
yourself only when every dealt structure fails audience identification or
|
||||
product clarity on named factual grounds.`;
|
||||
|
||||
const challengerInstruction = scope === 'direction'
|
||||
? `Fuse each challenger before judging it: the challenger supplies the form
|
||||
@@ -490,7 +516,7 @@ assigned index is suspended this round and the user picks; seed key ${key}.
|
||||
`
|
||||
: '';
|
||||
return `${degradedHeader}
|
||||
${degradedRegister}ASSIGNED INDEX: ${buildIndex}
|
||||
${degradedRegister}${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
|
||||
${promotedInstruction}
|
||||
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.
|
||||
@@ -514,8 +540,11 @@ channel: when a browser can open, present the direction on the decision page
|
||||
the no-browser fallback.
|
||||
${authorityInstruction}
|
||||
A user- or brief-pinned decision beats the roll, always.
|
||||
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.
|
||||
${scope === 'direction'
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||
: `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index
|
||||
${buildIndex} leads. Present all three dealt structures; seed key ${key}.`}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -595,7 +624,7 @@ rivals to your habitual layout, and keep only what makes this product clearer.${
|
||||
or IMPECCABLE_NO_TELEMETRY is set.\n`
|
||||
: '';
|
||||
const assignedBlock = register === null
|
||||
? `ASSIGNED INDEX: ${buildIndex}
|
||||
? `${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
|
||||
${promotedInstruction}
|
||||
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.`
|
||||
@@ -621,8 +650,11 @@ craft bar, the finish level and commitment the build is expected to reach,
|
||||
never as a mockup to copy; your surface serves this product, not that render.
|
||||
`;
|
||||
const restated = register === null
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
? (scope === 'direction'
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||
: `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index
|
||||
${buildIndex} leads. Present all three dealt structures; seed key ${key}.`)
|
||||
: `REGISTER (restated for truncated readers): ${register}, user-requested; the
|
||||
assigned index is suspended this round; seed key ${key}.`;
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
|
||||
|
||||
@@ -42,14 +42,15 @@ function latestCritique(cwd) {
|
||||
if (!latest) return null;
|
||||
const get = (key) => latest.meta[key] ?? null;
|
||||
const num = (v) => {
|
||||
if (v == null || (typeof v === 'string' && v.trim() === '')) return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
return {
|
||||
slug: get('slug'),
|
||||
score: num(get('score')),
|
||||
p0: num(get('p0')),
|
||||
p1: num(get('p1')),
|
||||
score: num(get('total_score') ?? get('score')),
|
||||
p0: num(get('p0_count') ?? get('p0')),
|
||||
p1: num(get('p1_count') ?? get('p1')),
|
||||
timestamp: get('timestamp'),
|
||||
file: path.relative(cwd, latest.path),
|
||||
};
|
||||
|
||||
@@ -1013,14 +1013,22 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
// still spelled out as the next command. The offer stays; the command leaves
|
||||
// this turn entirely, because installing over the skill mid-session changes
|
||||
// files the session is reading and only takes effect in the next one anyway.
|
||||
function buildUpdateDirective(localVersion, latestVersion) {
|
||||
return (
|
||||
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
|
||||
`(installed v${localVersion}, latest v${latestVersion}). ` +
|
||||
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
|
||||
`Mention it once, in this form: "A newer Impeccable (v${latestVersion}) is available. ` +
|
||||
`Update now? It runs \`npx impeccable update\`." ` +
|
||||
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
|
||||
`Either way, continue the current task without waiting, and do not raise this again.`
|
||||
`Do not run \`npx impeccable update\` in this turn, whatever the user answers: it rewrites the skill files ` +
|
||||
`this session is reading, and the update only takes effect in the next session, so there is nothing to gain now. ` +
|
||||
`Run it in a later turn, only after the user has asked for it in their own words. ` +
|
||||
`Continue the current task now without waiting, and do not raise this again.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1142,6 +1150,7 @@ async function cli() {
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
appendDetectorFallback(parts, ctx);
|
||||
appendImageGenDirective(parts);
|
||||
appendBuildPathDirective(parts, ctx);
|
||||
appendAutonomyCounterDirective(parts);
|
||||
appendSubagentAuthorizationDirective(parts);
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
@@ -1161,6 +1170,7 @@ async function cli() {
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
appendDetectorFallback(parts, ctx);
|
||||
appendImageGenDirective(parts);
|
||||
appendBuildPathDirective(parts, ctx);
|
||||
appendAutonomyCounterDirective(parts);
|
||||
appendSubagentAuthorizationDirective(parts);
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
@@ -1269,6 +1279,53 @@ function automaticHookMode(ctx) {
|
||||
}
|
||||
|
||||
|
||||
// Build-path preference: a workflow setting (comp-led vs code-led), read here
|
||||
// so every session starts knowing it without a file hunt. It rides the unified
|
||||
// config beside the hook and detector settings, and the gitignored local file
|
||||
// wins, because whether a machine has an image tool is a property of that
|
||||
// machine, not of the team's committed default. Absence stays silent;
|
||||
// new-work's own default applies, and the decision page toggle can flip the
|
||||
// value for a single session.
|
||||
function readBuildPathAt(root) {
|
||||
let value = null;
|
||||
let source = null;
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = readJson(path.join(root, '.impeccable', name));
|
||||
if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') {
|
||||
value = raw.buildPath;
|
||||
source = `.impeccable/${name}`;
|
||||
}
|
||||
}
|
||||
return value ? { value, source } : null;
|
||||
}
|
||||
|
||||
// Roots in precedence order, nearest first: the resolved project decides, and
|
||||
// the repo root is the fallback a monorepo commits once for every app in it.
|
||||
// `checkBuildPathUnset` reads exactly these two, and the pair has to match:
|
||||
// when they disagree the finding goes silent because a value exists while the
|
||||
// directive never names it, which is the one combination nobody can debug.
|
||||
//
|
||||
// The invoking directory is deliberately not in the chain. With `--target`
|
||||
// selecting another workspace, cwd is the caller's app, not the target's, and
|
||||
// letting it rank above the repo root hands one workspace another's workflow.
|
||||
// It stands in only when no project resolved at all.
|
||||
function appendBuildPathDirective(parts, ctx) {
|
||||
const roots = [...new Set(
|
||||
[ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((root) => path.resolve(root)),
|
||||
)];
|
||||
for (const root of roots) {
|
||||
const found = readBuildPathAt(root);
|
||||
if (!found) continue;
|
||||
// "Never written back" is scoped by the fact that this directive exists at
|
||||
// all: it is emitted only where a value is already recorded, which is the
|
||||
// case where a flip really is session-only. Saying so inline because the
|
||||
// bare absolute reads as a rule that overrides new-work's one-time offer,
|
||||
// which is exactly how the same wording misfired in serve-question.
|
||||
parts.push(`BUILD_PATH_DEFAULT: ${found.value} (from ${found.source}). Author direction and surface rounds with this as buildPath.value and toggle: true; a flip on the page binds that session only and is never written back, because a default is already recorded here. New-work's one-time offer to record a flipped value applies only where no default exists, which is why you are not seeing this line on those projects.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Image generation availability: harness-native tools always win, but when the
|
||||
// environment carries an OpenAI key the API fallback works everywhere. The
|
||||
// flag only reports capability, positively: absence stays silent, because a
|
||||
|
||||
@@ -14,6 +14,10 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
// reuse the r/g/b-only channel tolerance.
|
||||
const SHADOW_ALPHA_TOLERANCE = 0.02;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
||||
@@ -474,6 +478,25 @@ function addSidecarRadii(out, sidecar) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sidecar `extensions.shadows` entries ({ name, value, purpose }) carry the
|
||||
// documented shadow vocabulary that Stitch's frontmatter schema can't hold.
|
||||
// Their colors go into a separate allowlist — NOT allowedColorKeys — because a
|
||||
// shadow black is only documented *as a shadow*: feeding it into the general
|
||||
// color allowlist would legalize #000 as a page ground (alpha is dropped from
|
||||
// colorKey), which is the hole issue #547 warns against.
|
||||
function addSidecarShadows(out, sidecar) {
|
||||
const shadows = sidecar?.extensions?.shadows;
|
||||
if (!Array.isArray(shadows)) return;
|
||||
|
||||
for (const entry of shadows) {
|
||||
if (typeof entry?.value !== 'string') continue;
|
||||
for (const match of entry.value.matchAll(CSS_COLOR_RE)) {
|
||||
const parsed = parseDesignColor(match[0]);
|
||||
if (parsed) out.allowedShadowColors.push({ color: parsed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDesignSystem(input = {}) {
|
||||
const frontmatter = input.frontmatter || {};
|
||||
const sidecar = input.sidecar || null;
|
||||
@@ -486,6 +509,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
allowedColorKeys: new Map(),
|
||||
allowedRadii: [],
|
||||
allowedFontSizes: [],
|
||||
allowedShadowColors: [],
|
||||
hasPillRadius: false,
|
||||
};
|
||||
|
||||
@@ -495,6 +519,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
addSidecarColors(out, sidecar);
|
||||
addRoundedScale(out, frontmatter.rounded);
|
||||
addSidecarRadii(out, sidecar);
|
||||
addSidecarShadows(out, sidecar);
|
||||
|
||||
out.hasFonts = out.allowedFonts.size > 0;
|
||||
out.hasColors = out.allowedColorKeys.size > 0;
|
||||
@@ -614,6 +639,20 @@ function isAllowedColorRaw(raw, designSystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A color is a documented shadow color only when both the r/g/b channels AND
|
||||
// the alpha match a sidecar shadow token's color. Alpha has to be compared
|
||||
// here because colorKey()/colorsClose() drop it, and a match on r/g/b alone
|
||||
// would let every black at every alpha through.
|
||||
function isAllowedShadowColorRaw(raw, designSystem) {
|
||||
if (!designSystem?.allowedShadowColors?.length) return false;
|
||||
const parsed = parseDesignColor(String(raw || '').trim().toLowerCase());
|
||||
if (!parsed) return false;
|
||||
return designSystem.allowedShadowColors.some(entry =>
|
||||
colorsClose(parsed, entry.color) &&
|
||||
Math.abs((parsed.a ?? 1) - (entry.color.a ?? 1)) <= SHADOW_ALPHA_TOLERANCE,
|
||||
);
|
||||
}
|
||||
|
||||
function isAllowedRadiusRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasRadii) return true;
|
||||
const text = String(raw || '').trim().toLowerCase();
|
||||
@@ -691,6 +730,40 @@ function isProbablyColorLiteral(line, match) {
|
||||
return styleContext || cssFunctionContext || jsColorKeyContext;
|
||||
}
|
||||
|
||||
// One complete `${...}` template interpolation. Its content may carry paired
|
||||
// quoted strings (function arguments, ternary branches) and one level of
|
||||
// braces (an object-literal argument, itself allowing paired quotes). Deeper
|
||||
// nesting would need a parser, so the regex deliberately fails safe there:
|
||||
// the context check misses and the finding fires — a false positive a waiver
|
||||
// can silence, never a leak.
|
||||
const QUOTED_STRING_SRC = `"[^"]*"|'[^']*'`;
|
||||
const INTERPOLATION_SRC =
|
||||
`\\$\\{(?:${QUOTED_STRING_SRC}|\\{(?:${QUOTED_STRING_SRC}|[^{}"'\`])*\\}|[^{}"'\`])*\\}`;
|
||||
// The two shadow-context tails. Unlike jsColorKeyContext, the JS tail admits
|
||||
// commas: a multi-layer shadow string is comma-separated, and a later
|
||||
// property on the same line is still blocked because it sits past the
|
||||
// string's closing quote. Both tails admit complete interpolations; a bare
|
||||
// `}`, quote, or `;` still ends the context.
|
||||
const SHADOW_CSS_CONTEXT_RE = new RegExp(
|
||||
`(?:^|[{\\s;"'\`(,])(?:box-shadow|text-shadow)\\s*:\\s*(?:${INTERPOLATION_SRC}|[^;{}"'\`])*$`, 'i',
|
||||
);
|
||||
const SHADOW_JS_CONTEXT_RE = new RegExp(
|
||||
`(?:^|[,{]\\s*)(?:boxShadow|textShadow)\\s*[:=]\\s*["'\`]?(?:${INTERPOLATION_SRC}|[^"'\`}])*$`, 'i',
|
||||
);
|
||||
|
||||
// True when the color literal sits inside a box-shadow / text-shadow value —
|
||||
// the only contexts where a documented shadow color is legal. Anchored to the
|
||||
// end of `before` (no ; } { or quote in between) so a shadow property earlier
|
||||
// on the line can't leak the allowance into a later declaration. Kept separate
|
||||
// from isProbablyColorLiteral(), which stays a boolean for its existing call
|
||||
// sites and deliberately discards which property matched.
|
||||
function isShadowPropertyContext(line, match) {
|
||||
const index = match.index ?? -1;
|
||||
if (index < 0) return false;
|
||||
const before = line.slice(0, index);
|
||||
return SHADOW_CSS_CONTEXT_RE.test(before) || SHADOW_JS_CONTEXT_RE.test(before);
|
||||
}
|
||||
|
||||
function isInsideCssAttributeSelector(line, index) {
|
||||
if (index < 0) return false;
|
||||
const before = line.slice(0, index);
|
||||
@@ -824,6 +897,7 @@ function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
if (!isProbablyColorLiteral(line, match)) continue;
|
||||
const raw = cssColorLabel(match[0]);
|
||||
if (isAllowedColorRaw(raw, designSystem)) continue;
|
||||
if (isShadowPropertyContext(line, match) && isAllowedShadowColorRaw(raw, designSystem)) continue;
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-color',
|
||||
filePath,
|
||||
@@ -1038,6 +1112,7 @@ export {
|
||||
loadDesignSystemForCwd,
|
||||
isAllowedFont,
|
||||
isAllowedColorRaw,
|
||||
isAllowedShadowColorRaw,
|
||||
isAllowedRadiusRaw,
|
||||
isAllowedFontSizeRaw,
|
||||
checkSourceDesignSystem,
|
||||
|
||||
@@ -425,25 +425,28 @@ const REGEX_MATCHERS = [
|
||||
},
|
||||
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
|
||||
// --- Layout property transition ---
|
||||
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
|
||||
// JSX inline style objects use comma-delimited quoted values, not semicolons (issue #548).
|
||||
{ id: 'layout-transition', regex: /transition\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi,
|
||||
test: (m) => {
|
||||
const val = m[1].toLowerCase();
|
||||
const val = (m[2] ?? m[3] ?? '').toLowerCase();
|
||||
if (/\ball\b/.test(val)) return false;
|
||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||
},
|
||||
fmt: (m) => {
|
||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
|
||||
const raw = m[2] ?? m[3] ?? '';
|
||||
const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition: ${found ? found.join(', ') : raw.trim()}`;
|
||||
} },
|
||||
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
|
||||
{ id: 'layout-transition', regex: /transition-property\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi,
|
||||
test: (m) => {
|
||||
const val = m[1].toLowerCase();
|
||||
const val = (m[2] ?? m[3] ?? '').toLowerCase();
|
||||
if (/\ball\b/.test(val)) return false;
|
||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||
},
|
||||
fmt: (m) => {
|
||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
|
||||
const raw = m[2] ?? m[3] ?? '';
|
||||
const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition-property: ${found ? found.join(', ') : raw.trim()}`;
|
||||
} },
|
||||
// --- Broken image: src="" or src="#" or src=" " ---
|
||||
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkBuildPathUnset,
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
checkNativePlatformEvidence,
|
||||
@@ -120,6 +121,7 @@ async function collect(cwd, targetOptions) {
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...checkHookInstallation({
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
*
|
||||
* node generate-image.mjs --prompt "..." --out mock.png [--size 1536x1024] [--quality medium]
|
||||
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
|
||||
* node generate-image.mjs --prompt "..." --out mock.png --ref screenshot.png [--ref more.png]
|
||||
*
|
||||
* --ref anchors generation on input image(s) via the edits endpoint: pass a
|
||||
* captured screenshot of a representative existing page when comping a new
|
||||
* surface for an established world, so the identity comes from the real UI.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import zlib from 'node:zlib';
|
||||
@@ -212,12 +217,44 @@ if (!prompt || !out) {
|
||||
}
|
||||
const size = arg('size', '1536x1024');
|
||||
const quality = arg('quality', 'medium');
|
||||
// Reference images (--ref, repeatable): route through the edits endpoint,
|
||||
// which accepts input images. This is how a comp for an established world
|
||||
// inherits the real UI's identity from a captured screenshot instead of a
|
||||
// prose paraphrase of it; the prompt then describes the NEW surface and the
|
||||
// reference carries palette, type, and component character.
|
||||
const refs = (() => {
|
||||
const found = [];
|
||||
for (let i = 0; i < process.argv.length; i += 1) {
|
||||
if (process.argv[i] === '--ref' && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) found.push(process.argv[i + 1]);
|
||||
}
|
||||
return found;
|
||||
})();
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/images/generations', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
|
||||
});
|
||||
let response;
|
||||
if (refs.length) {
|
||||
const form = new FormData();
|
||||
form.append('model', 'gpt-image-2');
|
||||
form.append('prompt', prompt);
|
||||
form.append('size', size);
|
||||
form.append('quality', quality);
|
||||
form.append('n', '1');
|
||||
for (const ref of refs) {
|
||||
const bytes = fs.readFileSync(ref);
|
||||
const type = ref.endsWith('.png') ? 'image/png' : ref.endsWith('.webp') ? 'image/webp' : 'image/jpeg';
|
||||
form.append('image[]', new Blob([bytes], { type }), ref.split('/').pop());
|
||||
}
|
||||
response = await fetch('https://api.openai.com/v1/images/edits', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
body: form,
|
||||
});
|
||||
} else {
|
||||
response = await fetch('https://api.openai.com/v1/images/generations', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error(`generate-image: API error ${response.status}: ${(await response.text()).slice(0, 300)}`);
|
||||
process.exit(1);
|
||||
@@ -235,6 +272,6 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
|
||||
try {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2' }, null, 2));
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
|
||||
} catch { /* embedding is best-effort */ }
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
||||
|
||||
@@ -47,18 +47,33 @@ import {
|
||||
|
||||
// Top-level keys any reader honors: `hook` and `detector` subtrees (hook-lib's
|
||||
// readConfig), `updateCheck` (context.mjs), `projectRoots` (context.mjs's
|
||||
// monorepo resolution), plus `stalenessCheck` below. `$schema` and `version`
|
||||
// are allowed as conventional metadata nobody reads.
|
||||
// monorepo resolution), `buildPath` (context.mjs's build-path directive), plus
|
||||
// `stalenessCheck` below. `$schema` and `version` are allowed as conventional
|
||||
// metadata nobody reads.
|
||||
const KNOWN_CONFIG_KEYS = new Set([
|
||||
'hook',
|
||||
'detector',
|
||||
'updateCheck',
|
||||
'stalenessCheck',
|
||||
'projectRoots',
|
||||
'buildPath',
|
||||
'$schema',
|
||||
'version',
|
||||
]);
|
||||
|
||||
// The only two values context.mjs and new-work honor. A near miss reads as a
|
||||
// working preference and silently rides the opposite path, so it is worth
|
||||
// reporting rather than coercing.
|
||||
const BUILD_PATH_VALUES = Object.freeze(['comp', 'code']);
|
||||
|
||||
// Evidence that this project does the kind of work `buildPath` governs. A
|
||||
// project that only ever ran polish or audit has no use for the setting and
|
||||
// should never be told it exists. Two stats, so Tier 1 can afford it.
|
||||
const DIRECTION_WORK_PATHS = Object.freeze([
|
||||
path.join('.impeccable', 'surfaces'),
|
||||
path.join('.impeccable', 'mocks', 'decision'),
|
||||
]);
|
||||
|
||||
// `detector` is a closed set, so a typo here is worth reporting. `hook` is not
|
||||
// checked: it carries runtime settings from several writers and the false
|
||||
// positive rate would outweigh the catch.
|
||||
@@ -325,6 +340,20 @@ export function checkConfig({ projectRoot, repoRoot }) {
|
||||
}));
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'buildPath')
|
||||
&& !BUILD_PATH_VALUES.includes(raw.buildPath)) {
|
||||
findings.push(finding({
|
||||
id: 'config-invalid-build-path',
|
||||
artifact: 'config.json',
|
||||
filePath: rel,
|
||||
severity: 'mention',
|
||||
summary: `${rel} sets \`buildPath\` to ${JSON.stringify(raw.buildPath)}, which nothing reads. `
|
||||
+ `The values are ${BUILD_PATH_VALUES.map((value) => `\`${value}\``).join(' and ')}.`,
|
||||
fix: 'Report the value. An unread `buildPath` does not fall back to the other path; '
|
||||
+ 'it falls back to the default, so a project meaning `code` has been building comp-led.',
|
||||
}));
|
||||
}
|
||||
|
||||
const detector = raw.detector;
|
||||
if (detector && typeof detector === 'object' && !Array.isArray(detector)) {
|
||||
const unknownDetector = Object.keys(detector).filter((key) => !KNOWN_DETECTOR_KEYS.has(key));
|
||||
@@ -345,6 +374,47 @@ export function checkConfig({ projectRoot, repoRoot }) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* No recorded build-path preference on a project that plainly does visual
|
||||
* direction work. Not drift in the usual sense: the setting is newer than the
|
||||
* project, so every project that predates it lands here at once. That is why
|
||||
* it is gated twice, on a product record and on evidence of the work the
|
||||
* setting governs, and why it says the choice rather than assuming a harness
|
||||
* can make it. Image generation is the real precondition and this module
|
||||
* cannot see it: a harness-native image tool leaves no trace on disk, so the
|
||||
* finding hands the question to the one reader that knows.
|
||||
*/
|
||||
export function checkBuildPathUnset({ projectRoot, repoRoot, product }) {
|
||||
if (!projectRoot || !product) return [];
|
||||
const roots = [...new Set([projectRoot, repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
||||
|
||||
for (const root of roots) {
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = readJson(path.join(root, '.impeccable', name));
|
||||
// Any declared value ends this, valid or not: an invalid one already has
|
||||
// its own finding and two reports of one key is noise.
|
||||
if (raw && Object.prototype.hasOwnProperty.call(raw, 'buildPath')) return [];
|
||||
}
|
||||
}
|
||||
|
||||
const evidence = DIRECTION_WORK_PATHS.filter((rel) => fs.existsSync(path.join(projectRoot, rel)));
|
||||
if (!evidence.length) return [];
|
||||
|
||||
return [finding({
|
||||
id: 'config-build-path-unset',
|
||||
artifact: 'config.json',
|
||||
filePath: '.impeccable/config.json',
|
||||
severity: 'mention',
|
||||
summary: 'This project has run visual direction work but records no `buildPath`, '
|
||||
+ 'so every direction round takes the comp-first default without anyone having chosen it.',
|
||||
fix: 'Only when image generation exists in your tool surface, offer the choice once: '
|
||||
+ '**comp-first** (an image sets the bar before any code; bolder composition, slower) or '
|
||||
+ '**code-first** (build directly; ambition carried by the direction contract; leaner, faster). '
|
||||
+ 'Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, '
|
||||
+ 'merging with the keys already there. Without image generation there is no choice to record: stay silent.',
|
||||
})];
|
||||
}
|
||||
|
||||
// ─── Surface briefs ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -446,6 +516,7 @@ export function collectBootFindings(ctx, extras = {}) {
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
|
||||
@@ -170,51 +170,35 @@ Output (JSON):
|
||||
}
|
||||
|
||||
if (svelteComponentManifest) {
|
||||
if (isDiscard) {
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
||||
'discard:' + id,
|
||||
() => {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
return { handled: true };
|
||||
},
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err);
|
||||
}
|
||||
emitResult({
|
||||
...result,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
carbonize: false,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
||||
'accept:' + id,
|
||||
() => inlineSvelteComponentAccept(
|
||||
const { sourceFile, componentDir } = svelteComponentManifest;
|
||||
const resultContext = {
|
||||
file: sourceFile,
|
||||
...(isDiscard ? { carbonize: false } : { sourceFile }),
|
||||
previewMode: 'svelte-component',
|
||||
componentDir,
|
||||
};
|
||||
const runOperation = isDiscard
|
||||
? () => {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
return { handled: true, ...resultContext };
|
||||
}
|
||||
: () => inlineSvelteComponentAccept(
|
||||
svelteComponentManifest,
|
||||
variantNum,
|
||||
paramValues,
|
||||
process.cwd(),
|
||||
),
|
||||
);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), sourceFile),
|
||||
requestedOperation + ':' + id,
|
||||
runOperation,
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err, {
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
sourceFile: svelteComponentManifest.sourceFile,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
});
|
||||
result = operationFailure(err, resultContext);
|
||||
}
|
||||
if (result.carbonize) {
|
||||
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
|
||||
|
||||
@@ -22,6 +22,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
// All known harness directories
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
|
||||
'.hermes',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
||||
];
|
||||
|
||||
|
||||
@@ -42,14 +42,14 @@
|
||||
* // raise lines under the identity row
|
||||
* "risk": "one line: the honest risk", // optional
|
||||
* "body": "fallback prose when the structured fields are absent",
|
||||
* "sketch": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
|
||||
* // full-fidelity direction comp (the field
|
||||
* // keeps the sketch era's wire name). May not
|
||||
* // exist yet: the page shimmer-waits and
|
||||
* "comp": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
|
||||
* // full-fidelity direction comp (the legacy
|
||||
* // key "sketch" is accepted as an alias). May
|
||||
* // not exist yet: the page shimmer-waits and
|
||||
* // polls the slot until the file lands, so
|
||||
* // serve first and generate after
|
||||
* "hero": "https://... or /abs/path.webp", // optional inspiration image;
|
||||
* // rides picture-in-picture when a sketch exists
|
||||
* // rides picture-in-picture when a comp exists
|
||||
* "board": "https://... or /abs/path.webp" // optional secondary image
|
||||
* }, ...
|
||||
* ],
|
||||
@@ -61,7 +61,7 @@
|
||||
* "canon": true, // adds the "Play it straight" standing exit;
|
||||
* // direction rounds only (returns {"optionId":"canon"})
|
||||
* "canonCard": { ... }, // optional: the standing exit as a full card with the
|
||||
* // same anatomy (label, thesis, palette, sketch, ...);
|
||||
* // same anatomy (label, thesis, palette, comp, ...);
|
||||
* // rendered last and visually subordinate. Without it,
|
||||
* // canon stays a quiet footer action.
|
||||
* "steer": true, // adds a free-text steer field returned with any answer
|
||||
@@ -75,7 +75,7 @@
|
||||
* // then the execution contract.
|
||||
* }
|
||||
*
|
||||
* Options render as large cards: the sketch leads when present, with the
|
||||
* Options render as large cards: the comp leads when present, with the
|
||||
* inspiration image picture-in-picture; a hero alone renders full-bleed; a
|
||||
* text-only direction gets its identity from the palette chips and tags.
|
||||
* Local image paths are served by this server; nothing is uploaded anywhere.
|
||||
@@ -148,7 +148,7 @@ function printAnswer(raw) {
|
||||
if (a.hero || a.board) {
|
||||
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code. When your harness only reads files, or runs sandboxed, download them INTO the workspace and open the relative path; a sandboxed viewer rejects absolute paths outside it. They set the craft bar the build must reach.");
|
||||
}
|
||||
if (a.sketch) {
|
||||
if (a.comp) {
|
||||
console.log('CHOSEN COMP: the decision comp at that path is compositional option one. On a comp-led build the comp round adds two variations beside it; on a code-led build it returns at the finish review as the critique reference. Never regenerate it from scratch.');
|
||||
}
|
||||
if (a.optionId === 'canon') {
|
||||
@@ -160,6 +160,18 @@ function printAnswer(raw) {
|
||||
if (a.followup && a.optionId !== 'reroll') {
|
||||
console.log('FOLLOWUP OPEN: the table stays open and the page is showing a loading hand. Deliver the next round now with --update --key <key> --payload <file>, then collect it with --wait; never leave the page waiting on a round you have not sent.');
|
||||
}
|
||||
if (a.buildPath === 'comp' || a.buildPath === 'code') {
|
||||
// The page never writes the flip itself, but "never write it" overstated
|
||||
// that into a rule the agent then applied to new-work's one-time offer,
|
||||
// which exists for exactly this case: a flip on a project that had no
|
||||
// recorded default is the only moment the preference is ever asked for.
|
||||
const origin = a.buildPathFlipped
|
||||
? 'flipped on the page, so it binds this session only, and the page never writes it back; the sole exception is new-work’s one-time offer, on a project that had no recorded default at all, which asks after the round closes and writes the answer to .impeccable/config.json'
|
||||
: 'the round’s recorded default';
|
||||
console.log(`BUILD PATH: ${a.buildPath} (${origin}). ${a.buildPath === 'comp'
|
||||
? 'Comp-led: the chosen card’s comp is law; generate it before building when it does not exist yet, and the finish review audits the build against it.'
|
||||
: 'Code-led: no comp is owed; a comp that already rendered rides at the finish review as the critique reference, and the ambition lives in the direction contract.'}`);
|
||||
}
|
||||
} catch { /* raw answer */ }
|
||||
}
|
||||
|
||||
@@ -169,23 +181,29 @@ const portArg = Number(arg('port', '0'));
|
||||
const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions');
|
||||
const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`);
|
||||
const answerFile = (key) => path.join(QUESTION_DIR, `${key}.answer.json`);
|
||||
// A code-to-comp flip mid-round: the page records it here and --wait
|
||||
// surfaces it as its own event, because the agent must start generating
|
||||
// comps while the round is still open. Comp-to-code needs no event; it is
|
||||
// free and rides the final ANSWER.
|
||||
const flipFile = (key) => path.join(QUESTION_DIR, `${key}.flip.json`);
|
||||
|
||||
if (hasFlag('schema')) {
|
||||
console.log(JSON.stringify({
|
||||
title: 'Choose the visual world',
|
||||
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
|
||||
options: [
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], sketch: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
|
||||
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'MY PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as today’s ballad.', risk: 'Also the direction most runs in this category land on.', sketch: '.impeccable/mocks/decision/model-pick.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', sketch: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], comp: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
|
||||
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'IMPECCABLE’S PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as today’s ballad.', risk: 'Also the direction most runs in this category land on.', comp: '.impeccable/mocks/decision/model-pick.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', comp: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'challenger-microfiche', label: 'Microfiche Reader', verdict: 'declined', lineage: 'library microfiche stations', palette: ['#101418', '#9fb4c0'], materials: ['film grain', 'backlit glass'], case: 'Fuses poorly: listeners do not identify with archival retrieval.', kept: 'Total environmental commitment.', hero: 'https://impeccable.style/worlds/cards/archives-microfiche-reader-hero.webp' },
|
||||
],
|
||||
reroll: { registers: ['safer', 'bolder'] },
|
||||
buildPath: { value: 'comp', toggle: true },
|
||||
canon: true,
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', sketch: '.impeccable/mocks/decision/canon.webp' },
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', comp: '.impeccable/mocks/decision/canon.webp' },
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no sketch (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "MY PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. The sketch slot carries the card’s full-fidelity direction comp (the field keeps its wire name for compatibility). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. followup: true keeps the table open after a pick for a second round via --update (direction first, then the execution contract); send the next payload immediately, the page is waiting on it.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/comp accept URLs or local paths; comp slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the comp, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no comp (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "IMPECCABLE’S PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. A card may instead declare "wireframe" ({"cols":12,"rows":10,"regions":[{"label":"nav rail","x":0,"y":0,"w":3,"h":10,"accent":true}]}): the page draws it as a layout schematic in the media slot; surface-scope rounds use it on code-led builds, it never counts toward salience, and the card keeps its full read on the front. The comp slot carries the card’s full-fidelity direction comp (the legacy key "sketch" is accepted as an alias). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. buildPath rides the payload as { "value": "comp"|"code", "toggle": true }: the value is the recorded default (.impeccable/config.json buildPath, or .impeccable/config.local.json where one machine differs) and the toggle renders a footer switch whose flip binds that session only; the ANSWER then carries buildPath plus buildPathFlipped. On a code-led round each card still declares its comp path as a flip reserve: wireframes render, and a flip to comp makes --wait return once with BUILD PATH FLIPPED so you generate the comps into the declared slots while the round stays open; a flip back to code is free, and a comp that already landed stays as the critique reference. The toggle may only be offered when image generation exists: a harness with no image tool and no API key never sets toggle: true, so the choice never renders where comps cannot be made, and code-led simply rides as the untoggleable value. followup: true keeps the table open after a pick for a second round via --update; send the next payload immediately, the page is waiting on it.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -212,6 +230,13 @@ if (hasFlag('wait')) {
|
||||
let sawClose = false;
|
||||
while (Date.now() < deadline) {
|
||||
if (answered()) break;
|
||||
// A build-path flip is its own event, not an answer: the round stays
|
||||
// open, and the agent's job right now is comps, not code.
|
||||
if (fs.existsSync(flipFile(key))) {
|
||||
try { fs.rmSync(flipFile(key)); } catch { /* consumed elsewhere */ }
|
||||
console.log('BUILD PATH FLIPPED: comp (for this session only; never write it to settings). The table is still open and the page shows shimmer where the images will land: generate each open card’s comp into its declared path now, lead first, then collect the answer with --wait again. A card whose comp already exists needs nothing.');
|
||||
process.exit(0);
|
||||
}
|
||||
if (!alive()) {
|
||||
console.log('serve-question: the question server is gone with no answer. This is a server failure, not a user decision: restart it with --start and the same payload, reopen the URL for the user, and wait again. Never proceed without their choice while their browser session is open.');
|
||||
process.exit(2);
|
||||
@@ -307,6 +332,12 @@ else raw = fs.readFileSync(0, 'utf8');
|
||||
let payload;
|
||||
let options;
|
||||
let localImages = [];
|
||||
// Build path (comp-led vs code-led): the payload carries the recorded
|
||||
// default; the page's toggle updates the live value per session. The server
|
||||
// owns both so the final ANSWER states the path and whether it was flipped
|
||||
// even when the round never rendered a toggle.
|
||||
let buildPathDefault = null;
|
||||
let liveBuildPath = null;
|
||||
|
||||
function loadRound(json) {
|
||||
const parsed = JSON.parse(json);
|
||||
@@ -322,10 +353,10 @@ function loadRound(json) {
|
||||
localImages.push(abs);
|
||||
return `/img/${localImages.length - 1}`;
|
||||
};
|
||||
// Sketches stream in after the page is served, so their slots register
|
||||
// Comps stream in after the page is served, so their slots register
|
||||
// whether or not the file exists yet; /img answers 404 until it lands and
|
||||
// the page polls the slot. Remote sketch URLs pass through untouched.
|
||||
const sketchSrc = (value) => {
|
||||
// the page polls the slot. Remote comp URLs pass through untouched.
|
||||
const compSrc = (value) => {
|
||||
if (!value) return null;
|
||||
if (/^https?:\/\//.test(value)) return value;
|
||||
localImages.push(path.resolve(value));
|
||||
@@ -336,7 +367,7 @@ function loadRound(json) {
|
||||
...option,
|
||||
heroSrc: imageSrc(option.hero),
|
||||
boardSrc: imageSrc(option.board),
|
||||
sketchSrc: sketchSrc(option.sketch),
|
||||
compSrc: compSrc(option.comp ?? option.sketch),
|
||||
});
|
||||
options = parsed.options.map(decorate);
|
||||
// The verdict routes rendering: full cards first, then the canon, then the
|
||||
@@ -352,6 +383,10 @@ function loadRound(json) {
|
||||
options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
|
||||
}
|
||||
options = [...options, ...declined];
|
||||
buildPathDefault = (parsed.buildPath && (parsed.buildPath.value === 'comp' || parsed.buildPath.value === 'code'))
|
||||
? { value: parsed.buildPath.value, toggle: parsed.buildPath.toggle === true }
|
||||
: null;
|
||||
liveBuildPath = buildPathDefault?.value ?? null;
|
||||
}
|
||||
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
|
||||
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
|
||||
@@ -368,18 +403,25 @@ function page() {
|
||||
// no generation luck can distort.
|
||||
const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
|
||||
const demoted = (option) => option.verdict === 'declined';
|
||||
// The build path (comp-led vs code-led) is a workflow preference, not a
|
||||
// design decision: the payload carries the recorded default and whether
|
||||
// the page offers the toggle. On a code-led round a declared comp path is
|
||||
// a flip reserve, not a face: wireframes render, and the slot only starts
|
||||
// shimmering when the user flips to comp.
|
||||
const buildPath = buildPathDefault;
|
||||
const codeLed = buildPath?.value === 'code';
|
||||
// Salience parity: a card's imagery weight is capped by the assigned card's.
|
||||
// When the lead card has no media at all (no image generation this round,
|
||||
// and no catalog art of its own), full-bleed catalog art beside a text-only
|
||||
// assigned card would let rendering luck outvote the weighing: users click
|
||||
// the colorful thing. Declined cards are thumb-only regardless; the verdict
|
||||
// demoted them, and a full-bleed hero would promote them right back.
|
||||
const identityRound = !(options[0] && (options[0].sketchSrc || options[0].heroSrc || options[0].boardSrc));
|
||||
// A declined card never renders a full media face, sketch included: even a
|
||||
// declared sketch would buy back the salience the verdict took away.
|
||||
const faceSketch = (option) => demoted(option) ? null : option.sketchSrc;
|
||||
const thumbOnly = (option) => !faceSketch(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
|
||||
const hasMedia = (option) => Boolean(faceSketch(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
|
||||
const identityRound = !(options[0] && (options[0].compSrc || options[0].heroSrc || options[0].boardSrc));
|
||||
// A declined card never renders a full media face, comp included: even a
|
||||
// declared comp would buy back the salience the verdict took away.
|
||||
const faceComp = (option) => (demoted(option) || codeLed) ? null : option.compSrc;
|
||||
const thumbOnly = (option) => !faceComp(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
|
||||
const hasMedia = (option) => Boolean(faceComp(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
|
||||
// The back exists to keep long facts off a card whose front is an image;
|
||||
// a card with no art has no flip chip to reach it, so it gets no back and
|
||||
// the full read lives on the front instead.
|
||||
@@ -402,15 +444,16 @@ function page() {
|
||||
// generous hand cannot blow the card out of proportion.
|
||||
if (Array.isArray(option.raised) && option.raised.length) {
|
||||
const nameOf = (id) => options.find((o) => o.id === id)?.label || String(id ?? '');
|
||||
const raiseLines = option.raised.slice(0, 6).map((r) => `<p class="raise"><span class="fact-label">Raised by ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
|
||||
const raiseLines = option.raised.slice(0, 6).map((r) => `<p class="raise"><span class="fact-label">From ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
|
||||
const raisesHead = (count) => `<div class="raises-head"><span class="fact-label">Improved by Impeccable's worlds</span>${count > 1 ? `<span class="raises-count" data-raises-count>1/${count}</span>` : ''}</div>`;
|
||||
if (raiseLines.length > 1) {
|
||||
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter to see the next raise" aria-label="Raised by the hand; activate to see the next raise">
|
||||
<div class="raises-head"><span class="fact-label">Raised by the hand</span><span class="raises-count" data-raises-count>1/${raiseLines.length}</span></div>
|
||||
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter for the next improvement" aria-label="How Impeccable's worlds improved this direction; activate to see the next improvement">
|
||||
${raisesHead(raiseLines.length)}
|
||||
${raiseLines.join('')}
|
||||
<span class="sr-live" aria-live="polite"></span>
|
||||
</div>`);
|
||||
} else {
|
||||
rows.push(`<div class="raises">${raiseLines[0]}</div>`);
|
||||
rows.push(`<div class="raises">${raisesHead(1)}${raiseLines[0]}</div>`);
|
||||
}
|
||||
}
|
||||
// Demoted art stays reachable as a labeled thumb: the catalog world
|
||||
@@ -421,7 +464,7 @@ function page() {
|
||||
}
|
||||
// The front carries only what the choice needs: thesis, identity, and the
|
||||
// honest risk clamped to two lines. First viewport and the case read on
|
||||
// the card's back; once the sketch lands, the first viewport is a picture.
|
||||
// the card's back; once the comp lands, the first viewport is a picture.
|
||||
// With no art there is no back, so the full read fills the room the
|
||||
// image would have taken.
|
||||
if (hasMedia(option)) {
|
||||
@@ -444,24 +487,27 @@ function page() {
|
||||
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
|
||||
].filter(Boolean).join('\n ');
|
||||
const media = (option) => {
|
||||
const inspiration = option.heroSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(option.heroSrc)}" alt="">
|
||||
const inspirationSrc = option.heroSrc || option.boardSrc;
|
||||
const inspiration = inspirationSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(inspirationSrc)}" alt="">
|
||||
<figcaption>inspiration</figcaption>
|
||||
</figure>` : '';
|
||||
const details = hasBack(option) ? flipChip('Details') : '';
|
||||
// Thumb-only art renders inside the body via anatomy(), never as a face,
|
||||
// and a declined card's sketch slot is ignored outright.
|
||||
// and a declined card's comp slot is ignored outright.
|
||||
if (thumbOnly(option)) return '';
|
||||
if (faceSketch(option)) {
|
||||
return `<div class="media sketching" data-sketch="${esc(option.sketchSrc)}">
|
||||
<div class="shimmer"><span class="sketch-note">rendering…</span></div>
|
||||
<img class="sketch" alt="" hidden>
|
||||
if (faceComp(option)) {
|
||||
const textOnlyFacts = backFacts(option);
|
||||
return `<div class="media comp-pending" data-comp="${esc(option.compSrc)}">
|
||||
<div class="shimmer"><span class="comp-note">rendering…</span></div>
|
||||
<img class="comp" alt="" hidden>
|
||||
${inspiration}
|
||||
<template class="text-only-facts">${textOnlyFacts}</template>
|
||||
<div class="chips">${expandChip}${details}</div>
|
||||
</div>`;
|
||||
}
|
||||
if (option.heroSrc || option.boardSrc) {
|
||||
// Without a sketch the catalog art is the card's face; it stays a
|
||||
// Without a comp the catalog art is the card's face; it stays a
|
||||
// labeled reference so it never reads as the promise of the build.
|
||||
return `<div class="media" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||
@@ -471,13 +517,36 @@ function page() {
|
||||
}
|
||||
return '';
|
||||
};
|
||||
// Wireframe media: a code-led card's layout schematic, authored as grid
|
||||
// regions in the payload and drawn by the page; boxes and labels, no art.
|
||||
// It fills the media slot only when the card has no imagery, and it never
|
||||
// counts toward salience or earns a card back: the full read stays on the
|
||||
// front, exactly like a text-only card.
|
||||
const wire = (option) => {
|
||||
const frame = option.wireframe;
|
||||
if (!frame || !Array.isArray(frame.regions) || !frame.regions.length || media(option) || demoted(option)) return '';
|
||||
const cols = Number(frame.cols) > 0 ? Number(frame.cols) : 12;
|
||||
const rows = Number(frame.rows) > 0 ? Number(frame.rows) : 10;
|
||||
const pct = (n, total) => `${Math.max(0, Math.min(100, (n / total) * 100)).toFixed(2)}%`;
|
||||
const cells = frame.regions.slice(0, 12).map((region) => {
|
||||
const x = Number(region.x) || 0;
|
||||
const y = Number(region.y) || 0;
|
||||
const w = Math.max(Number(region.w) || 1, 0.5);
|
||||
const h = Math.max(Number(region.h) || 1, 0.5);
|
||||
return `<div class="wire-region${region.accent ? ' accent' : ''}" style="left:${pct(x, cols)};top:${pct(y, rows)};width:${pct(w, cols)};height:${pct(h, rows)}"><span>${esc(region.label || '')}</span></div>`;
|
||||
}).join('');
|
||||
return `<div class="media wire" role="img" aria-label="Layout schematic">
|
||||
<div class="wire-field">${cells}</div>
|
||||
<p class="media-label">layout</p>
|
||||
</div>`;
|
||||
};
|
||||
const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this';
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}"${codeLed && option.compSrc && !demoted(option) ? ` data-comp-slot="${esc(option.compSrc)}"` : ''}>
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${(media(option) || wire(option)) ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : demoted(option) ? '<span class="kicker declined-k">Declined</span>' : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
|
||||
${media(option)}
|
||||
${media(option) || wire(option)}
|
||||
<div class="body">
|
||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||
<h2>${esc(option.label)}</h2>
|
||||
@@ -526,9 +595,12 @@ function page() {
|
||||
--ks-font-display: "Alumni Sans", "Albert Sans", Arial, sans-serif;
|
||||
--ks-font: "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, system-ui, sans-serif;
|
||||
--ks-mono: "SFMono-Regular", "Roboto Mono", "JetBrains Mono", Consolas, monospace;
|
||||
/* One inset shared by the content column, the deck's snap padding, and
|
||||
the sticky footer, so all three align on the same 90rem column. */
|
||||
--page-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2));
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 2rem; min-height: 100dvh; display: flex; flex-direction: column; overflow-x: clip; }
|
||||
body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 0; min-height: 100dvh; display: flex; flex-direction: column; overflow-x: clip; }
|
||||
#ambient { position: fixed; inset: -40px; z-index: 0; background-size: cover; background-position: center; filter: blur(34px) saturate(1.05); opacity: 0; transition: opacity .55s ease, background-image .2s; pointer-events: none; }
|
||||
#scrim { position: fixed; inset: 0; z-index: 0; background: linear-gradient(180deg, oklch(7% 0.006 95 / 0.62), oklch(7% 0.006 95 / 0.78)); pointer-events: none; }
|
||||
header, main, footer { position: relative; z-index: 1; }
|
||||
@@ -540,7 +612,7 @@ function page() {
|
||||
.brand { display: flex; align-items: center; gap: .55rem; color: var(--ks-kinpaku); }
|
||||
.brand svg { width: 22px; height: 22px; }
|
||||
.wordmark { font-family: var(--ks-font-display); font-weight: 400; font-size: 1.125rem; letter-spacing: 0.15em; text-transform: uppercase; line-height: 1; color: var(--ks-kinpaku); }
|
||||
.headline { display: flex; align-items: center; gap: .9rem; }
|
||||
.headline { display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; }
|
||||
.headline-die { flex: none; width: 34px; height: 34px; color: var(--ks-kinpaku); }
|
||||
h1 { font-family: var(--ks-font-display); font-weight: 100; font-size: clamp(2.6rem, 5vw, 4.2rem); letter-spacing: -0.01em; line-height: 1.02; color: var(--ks-champagne); }
|
||||
.question { color: var(--ks-text-muted); margin-top: .7rem; max-width: 52rem; }
|
||||
@@ -552,12 +624,24 @@ function page() {
|
||||
.deck-shell { position: relative; width: 100vw; margin-left: calc(50% - 50vw); }
|
||||
/* One row in a wide viewport, one column in a tall one; the deck scrolls on
|
||||
its axis with snap points and the arrows page it card by card. */
|
||||
.grid { --deck-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2)); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; }
|
||||
.grid { --deck-inset: var(--page-inset); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; }
|
||||
.grid::-webkit-scrollbar { display: none; }
|
||||
/* Wide enough that the sketch carries the card: at 27vw the imagery read
|
||||
/* Wide enough that the comp carries the card: at 27vw the imagery read
|
||||
as a thumbnail above a column of copy, and the copy won the attention
|
||||
contest the sketch is supposed to win. */
|
||||
contest the comp is supposed to win. */
|
||||
.grid > .card { flex: 0 0 clamp(24rem, 34vw, 34rem); scroll-snap-align: center; }
|
||||
/* Short landscape viewports (13-inch laptops): header, a 34vw card, and the
|
||||
footer do not fit 800px of height, so the headline compacts and the deck
|
||||
narrows. Height is the axis that gives; the sticky footer keeps the
|
||||
round's verbs on screen while a too-tall card scrolls. */
|
||||
@media (min-aspect-ratio: 1/1) and (max-height: 900px) {
|
||||
body { padding-top: 1.1rem; }
|
||||
h1 { font-size: clamp(2rem, 3.4vw, 2.9rem); }
|
||||
.question { margin-top: .45rem; }
|
||||
.stage { gap: 1rem; }
|
||||
.grid > .card { flex-basis: clamp(20rem, 27vw, 27rem); }
|
||||
.grid > .card.declined { flex-basis: clamp(13rem, 18vw, 18rem); }
|
||||
}
|
||||
.nav { position: absolute; z-index: 6; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: oklch(7% 0.006 95 / 0.78); border: 1px solid var(--ks-rule); color: var(--ks-kinpaku); cursor: pointer; backdrop-filter: blur(6px); transition: border-color .2s, color .2s, opacity .2s; }
|
||||
.nav:hover { border-color: var(--ks-kinpaku-deep); color: var(--ks-kinpaku-pale); }
|
||||
.nav[disabled] { opacity: .25; cursor: default; }
|
||||
@@ -588,6 +672,10 @@ function page() {
|
||||
shrink a declined card to content WIDTH, not height, so it stretches
|
||||
like every other card and its height is already its own. */
|
||||
.grid > .card.declined { align-self: stretch; }
|
||||
/* The sticky bar is a wide-viewport fix. Here it would sit over the
|
||||
deck's More pager and cost a third of a phone screen, and the deck
|
||||
already scrolls internally, so the footer stays in the page flow. */
|
||||
footer { position: static; width: auto; margin: 1rem 0 0; padding: .7rem 0 1.2rem; background: transparent; border-top: 0; backdrop-filter: none; }
|
||||
}
|
||||
.card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
|
||||
.card:hover { transform: rotate(0deg) translateY(-4px); }
|
||||
@@ -611,7 +699,7 @@ function page() {
|
||||
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; }
|
||||
/* 16/10 matches the landscape sketch frame; portrait art overrides the
|
||||
/* 16/10 matches the landscape comp frame; portrait art overrides the
|
||||
slot with its own exact ratio at load (see the load listener), and the
|
||||
deck narrows so portrait cards line up side by side. */
|
||||
.media { position: relative; width: 100%; aspect-ratio: 16/10; flex: none; }
|
||||
@@ -647,14 +735,14 @@ function page() {
|
||||
.body.back-body { overflow-y: auto; flex: 1; scrollbar-width: thin; }
|
||||
/* Inspiration rides picture-in-picture: the catalog world explains where the
|
||||
direction comes from without promising what the build will look like. */
|
||||
/* Hovering the inspiration takes over the whole media region; the sketch is
|
||||
/* Hovering the inspiration takes over the whole media region; the comp is
|
||||
the promise, the inspiration is a glance, so the glance must cost nothing. */
|
||||
.pip { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; width: 84px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; background: var(--ks-lacquer); cursor: zoom-in; transition: left .35s cubic-bezier(.16,1,.3,1), bottom .35s cubic-bezier(.16,1,.3,1), width .35s cubic-bezier(.16,1,.3,1), height .35s cubic-bezier(.16,1,.3,1), border-radius .35s ease; box-shadow: 0 6px 18px oklch(0% 0 0 / 0.45); }
|
||||
.pip img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 3px 0 4px; background: oklch(7% 0.006 95 / 0.72); backdrop-filter: blur(3px); }
|
||||
.pip:hover { left: 0; bottom: 0; width: 100%; height: 100%; border-radius: 0; z-index: 3; }
|
||||
.sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||
/* Catalog art standing in for a sketchless card is a reference, and says so
|
||||
.comp-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||
/* Catalog art standing in for a comp-less card is a reference, and says so
|
||||
on its face; the same pill later carries "artwork unavailable". */
|
||||
.media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); padding: 3px 8px 4px; background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 4px; backdrop-filter: blur(3px); }
|
||||
/* Art that never arrives collapses to the card's own palette (painted
|
||||
@@ -666,15 +754,15 @@ function page() {
|
||||
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
|
||||
.media.unavailable .chips { z-index: 2; }
|
||||
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
||||
the real sketch whenever it lands. */
|
||||
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
||||
the real comp whenever it lands. */
|
||||
.media.stand-in img.comp { filter: brightness(.72) saturate(.85); }
|
||||
.media.stand-in .pip { display: none; }
|
||||
.stand-in-label { position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; margin: 0; font-family: var(--ks-mono); font-size: .56rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 4px 0 5px; background: oklch(7% 0.006 95 / 0.78); backdrop-filter: blur(3px); }
|
||||
.media.sketching { position: relative; }
|
||||
.media.sketching .shimmer { position: absolute; inset: 0; }
|
||||
.media img.sketch { position: relative; z-index: 1; }
|
||||
.media.comp-pending { position: relative; }
|
||||
.media.comp-pending .shimmer { position: absolute; inset: 0; }
|
||||
.media img.comp { position: relative; z-index: 1; }
|
||||
/* The generic .media img display:block would defeat [hidden] and float an
|
||||
empty block over the shimmer; an unloaded sketch must truly not render. */
|
||||
empty block over the shimmer; an unloaded comp must truly not render. */
|
||||
.media img[hidden] { display: none; }
|
||||
/* Declined challengers: the weighing demoted them, so the card is narrower
|
||||
and quieter, its catalog art rides as a labeled thumb in the body, and
|
||||
@@ -690,22 +778,33 @@ function page() {
|
||||
.kicker.declined-k { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); }
|
||||
.card.declined button.choose { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font-size: .85rem; padding: 8px 22px; }
|
||||
.card.declined button.choose:hover { background: var(--ks-graphite-2); border-color: var(--ks-text-muted); }
|
||||
/* Wireframe media: the code-led schematic. Quiet boxes in the card's own
|
||||
chrome; uniform salience across cards by construction, so it needs no
|
||||
parity rules. */
|
||||
.media.wire { background: var(--ks-lacquer); border-bottom: 1px solid var(--ks-rule); }
|
||||
.wire-field { position: absolute; inset: 12px 12px 26px; }
|
||||
.wire-region { position: absolute; border: 1px solid oklch(78% 0 0 / 0.26); border-radius: 3px; background: oklch(78% 0 0 / 0.05); display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
||||
.wire-region span { font-family: var(--ks-mono); font-size: .55rem; letter-spacing: .1em; text-transform: uppercase; color: var(--ks-text-faint); text-align: center; padding: 2px 4px; }
|
||||
.wire-region.accent { border-color: oklch(84% 0.19 80.46 / 0.5); background: oklch(84% 0.19 80.46 / 0.06); }
|
||||
.wire-region.accent span { color: var(--ks-kinpaku-rich); }
|
||||
/* Thumb-scale inspiration: present, labeled, zoomable, and incapable of
|
||||
outshouting a text-only assigned card. */
|
||||
.inspo { position: relative; flex: none; margin: 2px 0; width: 104px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; cursor: zoom-in; background: var(--ks-lacquer); }
|
||||
.inspo img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.inspo figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .48rem; letter-spacing: .16em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 2px 0 3px; background: oklch(7% 0.006 95 / 0.72); }
|
||||
/* Raises: the donations the assigned direction took from the hand it beat,
|
||||
each named for its donor. Patina, not kinpaku: a raise is provenance. */
|
||||
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; }
|
||||
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; border-left: 2px solid var(--ks-patina); padding-left: 8px; }
|
||||
/* Raises: the improvements the dealt worlds donated to the assigned
|
||||
direction, each named for its donor world. Patina, not kinpaku:
|
||||
provenance, not a call to action. A quiet contained panel, never an
|
||||
accent side-tab. */
|
||||
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; padding: 7px 10px 8px; background: oklch(70% 0.12 188 / 0.06); border: 1px solid oklch(70% 0.12 188 / 0.22); border-radius: 8px; }
|
||||
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; }
|
||||
.raise .fact-label { color: var(--ks-patina); }
|
||||
/* Several raises cycle instead of stacking: one visible at a time, a
|
||||
/* Several kept ideas cycle instead of stacking: one visible at a time, a
|
||||
counter for the rest, the whole block advances on click. */
|
||||
.raises-cycle { cursor: pointer; border-radius: 6px; }
|
||||
.raises-cycle .raise { display: none; border-left: none; padding-left: 0; }
|
||||
.raises-cycle { cursor: pointer; transition: border-color .2s ease; }
|
||||
.raises-cycle:hover { border-color: oklch(70% 0.12 188 / 0.45); }
|
||||
.raises-cycle .raise { display: none; }
|
||||
.raises-cycle .raise.active { display: block; }
|
||||
.raises-cycle { border-left: 2px solid var(--ks-patina); padding-left: 8px; }
|
||||
.raises-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.raises-head .fact-label { color: var(--ks-patina); }
|
||||
.raises-count { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .14em; color: var(--ks-text-faint); }
|
||||
@@ -721,9 +820,39 @@ function page() {
|
||||
.card.canon button.choose:hover { border-color: var(--ks-text-muted); background: var(--ks-graphite-2); }
|
||||
button.choose { margin-top: auto; align-self: start; background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font-family: var(--ks-font); font-size: 1rem; font-weight: 500; line-height: 1.35; padding: 10px 38px; border-radius: 6px; cursor: pointer; transition: background .15s; }
|
||||
button.choose:hover { background: var(--ks-kinpaku-pale); }
|
||||
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
||||
/* The round's verbs stay reachable on short viewports: the footer is a
|
||||
full-bleed bar stuck to the viewport bottom and the deck scrolls under
|
||||
it. Same inset as the content column, so the controls stay aligned. */
|
||||
footer { position: sticky; bottom: 0; z-index: 10; width: 100vw; margin: 1.2rem calc(50% - 50vw) 0; padding: .7rem var(--page-inset) calc(.7rem + env(safe-area-inset-bottom, 0px)); display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: oklch(7% 0.006 95 / 0.82); backdrop-filter: blur(10px); border-top: 1px solid var(--ks-rule); }
|
||||
#steer { flex: 1; min-width: 16rem; background: var(--ks-lacquer-raised); color: var(--ks-text); border: 1px solid var(--ks-rule); border-radius: 7px; padding: .6rem .85rem; font: inherit; }
|
||||
#steer:focus { outline: none; border-color: var(--ks-patina); }
|
||||
/* Build-path toggle: a workflow preference surfaced as a quiet segmented
|
||||
control on the headline row, right-aligned opposite the title, its trade stated in
|
||||
one line that changes with the selection. The default comes from the
|
||||
payload (settings); flipping binds this session only, and the agent
|
||||
learns about a code-to-comp flip live. Rendered only when the payload
|
||||
offers it, which the agent does only when image generation exists. */
|
||||
#build-path { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; flex: none; margin-left: auto; }
|
||||
.bp-switch { display: inline-flex; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; }
|
||||
.bp-note { text-align: right; }
|
||||
.bp-opt { font-family: var(--ks-mono); font-size: .62rem; letter-spacing: .12em; text-transform: uppercase; padding: 7px 12px; background: transparent; border: 0; color: var(--ks-text-faint); cursor: pointer; transition: color .2s ease, background-color .2s ease; }
|
||||
.bp-opt + .bp-opt { border-left: 1px solid var(--ks-rule); }
|
||||
.bp-opt.active { color: var(--ks-dark-ink); background: var(--ks-kinpaku-rich); }
|
||||
.bp-opt:not(.active):hover { color: var(--ks-text); }
|
||||
.bp-note { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .04em; color: var(--ks-text-faint); max-width: 21rem; line-height: 1.5; }
|
||||
/* Flipping to comp starts billed, minutes-long generation, so it asks
|
||||
first; flipping back is free and never does. */
|
||||
#bp-confirm { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; background: oklch(4% 0.004 95 / 0.72); opacity: 0; transition: opacity .2s ease; }
|
||||
#bp-confirm[hidden] { display: none; }
|
||||
#bp-confirm.open { opacity: 1; }
|
||||
.bp-confirm-panel { max-width: 26rem; margin: 1rem; background: var(--ks-lacquer-raised); border: 1px solid var(--ks-rule); border-radius: 10px; padding: 1.4rem 1.5rem 1.3rem; box-shadow: 0 30px 80px oklch(0% 0 0 / 0.55); }
|
||||
.bp-confirm-panel h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; color: var(--ks-champagne); margin-bottom: .55rem; }
|
||||
.bp-confirm-panel p { font-size: .875rem; line-height: 1.55; color: var(--ks-text-muted); }
|
||||
.bp-confirm-actions { display: flex; gap: .6rem; margin-top: 1.1rem; }
|
||||
.bp-confirm-go { background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font: inherit; font-weight: 500; padding: 9px 22px; border-radius: 6px; cursor: pointer; }
|
||||
.bp-confirm-go:hover { background: var(--ks-kinpaku-pale); }
|
||||
.bp-confirm-stay { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font: inherit; padding: 9px 18px; border-radius: 6px; cursor: pointer; }
|
||||
.bp-confirm-stay:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
|
||||
.reroll-btn { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
.reroll-btn:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
.reroll-btn svg { width: 15px; height: 15px; }
|
||||
@@ -752,6 +881,16 @@ function page() {
|
||||
<div id="ambient" aria-hidden="true"></div>
|
||||
<div id="scrim" aria-hidden="true"></div>
|
||||
<div id="lightbox" hidden><img alt=""></div>
|
||||
${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria-labelledby="bp-confirm-title" hidden>
|
||||
<div class="bp-confirm-panel">
|
||||
<h2 id="bp-confirm-title">Flip to comp-first?</h2>
|
||||
<p>The agent starts rendering a comp for every open card right away, about a minute or two per card on your image provider, and the images land on the cards as they finish. This flip binds this session only.</p>
|
||||
<div class="bp-confirm-actions">
|
||||
<button type="button" class="bp-confirm-go" data-confirm>Render comps</button>
|
||||
<button type="button" class="bp-confirm-stay" data-cancel>Keep code-first</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<header>
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>
|
||||
@@ -763,6 +902,13 @@ function page() {
|
||||
<div class="headline">
|
||||
<svg class="headline-die" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>
|
||||
<h1>${esc(payload.title || 'Choose a direction')}</h1>
|
||||
${buildPath?.toggle ? `<div id="build-path" data-default="${buildPath.value}">
|
||||
<div class="bp-switch" role="radiogroup" aria-label="Build path">
|
||||
<button type="button" class="bp-opt" data-bp="comp" role="radio" aria-checked="false">Comp first</button>
|
||||
<button type="button" class="bp-opt" data-bp="code" role="radio" aria-checked="false">Code first</button>
|
||||
</div>
|
||||
<p class="bp-note" data-bp-note></p>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
${payload.question ? `<p class="question">${esc(payload.question)}</p>` : ''}
|
||||
<div class="deck-shell">
|
||||
@@ -822,7 +968,7 @@ function page() {
|
||||
if (count) count.textContent = (at + 1) + '/' + raises.length;
|
||||
// Screen readers hear the raise they just advanced to; the initial
|
||||
// render stays quiet so page load does not narrate every card.
|
||||
if (announce && live) live.textContent = 'Raise ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');
|
||||
if (announce && live) live.textContent = 'Improvement ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');
|
||||
};
|
||||
show(false);
|
||||
const advance = (e) => { e.stopPropagation(); at = (at + 1) % raises.length; show(true); };
|
||||
@@ -863,46 +1009,147 @@ function page() {
|
||||
}));
|
||||
}
|
||||
|
||||
// Sketches stream in after the deal: poll each slot until the file lands,
|
||||
// Comps stream in after the deal: poll each slot until the file lands,
|
||||
// then swap the shimmer for the image. Generation is genuinely slow and a
|
||||
// sequential batch puts the last card many minutes out, so patience is the
|
||||
// default: a slot only shows its inspiration as a stand-in when it has
|
||||
// waited four minutes AND nothing has landed anywhere for four minutes, the
|
||||
// stand-in is labeled as such, and polling continues so the real sketch
|
||||
// stand-in is labeled as such, and polling continues so the real comp
|
||||
// still swaps in whenever it arrives. Progress anywhere resets patience.
|
||||
const landTracker = { last: Date.now() };
|
||||
document.querySelectorAll('.media.sketching').forEach(m => {
|
||||
const url = m.dataset.sketch;
|
||||
const img = m.querySelector('img.sketch');
|
||||
const note = m.querySelector('.sketch-note');
|
||||
const pollComp = (m) => {
|
||||
const url = m.dataset.comp;
|
||||
const img = m.querySelector('img.comp');
|
||||
const note = m.querySelector('.comp-note');
|
||||
const started = Date.now();
|
||||
// A live elapsed count is the difference between "working" and "frozen".
|
||||
const tick = setInterval(() => { if (note) note.textContent = 'rendering · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
|
||||
const settle = () => { clearInterval(tick); m.classList.remove('sketching', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
|
||||
const standIn = () => {
|
||||
const settle = () => { clearInterval(tick); m.classList.remove('comp-pending', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
|
||||
const fallback = () => {
|
||||
const pip = m.querySelector('.pip img');
|
||||
if (!pip || m.classList.contains('stand-in')) return;
|
||||
img.src = pip.getAttribute('src'); img.hidden = false;
|
||||
m.classList.add('stand-in');
|
||||
m.querySelector('.shimmer')?.remove();
|
||||
clearInterval(tick);
|
||||
const label = document.createElement('p');
|
||||
label.className = 'stand-in-label';
|
||||
label.textContent = 'inspiration · comp pending';
|
||||
m.appendChild(label);
|
||||
if (pip) {
|
||||
if (m.classList.contains('stand-in')) return false;
|
||||
img.src = pip.getAttribute('src'); img.hidden = false;
|
||||
m.classList.add('stand-in');
|
||||
m.querySelector('.shimmer')?.remove();
|
||||
clearInterval(tick);
|
||||
const label = document.createElement('p');
|
||||
label.className = 'stand-in-label';
|
||||
label.textContent = 'inspiration · comp pending';
|
||||
m.appendChild(label);
|
||||
return false;
|
||||
}
|
||||
|
||||
// No comp and no inspiration is the text-only card the payload would
|
||||
// have rendered without a comp declaration. Bring the complete read
|
||||
// forward before removing the now-unreachable back face.
|
||||
const card = m.closest('.card');
|
||||
const front = card?.querySelector('.face.front');
|
||||
const body = front?.querySelector('.body');
|
||||
const back = card?.querySelector('.face.back');
|
||||
const textOnlyFacts = m.querySelector('template.text-only-facts');
|
||||
const choose = body?.querySelector(':scope > button.choose');
|
||||
if (body && textOnlyFacts && choose) {
|
||||
const plainDetail = body.querySelector(':scope > .detail:not(.more)');
|
||||
[...body.children].filter((el) => el.classList.contains('fact') || el.matches('.detail.more')).forEach((el) => el.remove());
|
||||
choose.before(textOnlyFacts.content.cloneNode(true));
|
||||
if (plainDetail) choose.before(plainDetail);
|
||||
}
|
||||
card?.classList.remove('flipped');
|
||||
front?.classList.add('text-only');
|
||||
back?.remove();
|
||||
settle();
|
||||
m.remove();
|
||||
return true;
|
||||
};
|
||||
const tryLoad = () => {
|
||||
// A slot the user flipped back out of leaves the DOM; let its loop die.
|
||||
if (!m.isConnected) { clearInterval(tick); return; }
|
||||
const probe = new Image();
|
||||
probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); };
|
||||
probe.onerror = () => {
|
||||
const quiet = Date.now() - landTracker.last > 240000;
|
||||
if (Date.now() - started > 240000 && quiet) standIn();
|
||||
if (Date.now() - started > 240000 && quiet && fallback()) return;
|
||||
setTimeout(tryLoad, m.classList.contains('stand-in') ? 5000 : 2500);
|
||||
};
|
||||
probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||
};
|
||||
tryLoad();
|
||||
});
|
||||
};
|
||||
document.querySelectorAll('.media.comp-pending').forEach(pollComp);
|
||||
|
||||
// Build-path toggle: the default is the round's recorded preference and
|
||||
// flipping binds this session only. Flipping code to comp swaps every
|
||||
// reserve slot (data-comp-slot) to its shimmer and tells the server, so
|
||||
// the waiting agent starts generating; flipping back is free: pending
|
||||
// slots return to their wireframes, a comp that already landed stays.
|
||||
const bp = document.getElementById('build-path');
|
||||
if (bp) {
|
||||
const notes = {
|
||||
comp: 'An image sets the bar first and the build must match it. Bolder composition; comps render before code.',
|
||||
code: 'Code builds directly; the ambition is written into the contract and audited at the finish. Leaner, faster.',
|
||||
};
|
||||
const noteEl = bp.querySelector('[data-bp-note]');
|
||||
let current = bp.dataset.default;
|
||||
const set = (value) => {
|
||||
current = value;
|
||||
bp.querySelectorAll('.bp-opt').forEach(b => {
|
||||
const on = b.dataset.bp === value;
|
||||
b.classList.toggle('active', on);
|
||||
b.setAttribute('aria-checked', String(on));
|
||||
});
|
||||
if (noteEl) noteEl.textContent = notes[value];
|
||||
};
|
||||
set(current);
|
||||
const enterComp = () => {
|
||||
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
|
||||
const front = card.querySelector('.face.front');
|
||||
if (!front || front.querySelector('.media.comp-pending') || front.querySelector('.media img.comp:not([hidden])')) return;
|
||||
const m = document.createElement('div');
|
||||
m.className = 'media comp-pending';
|
||||
m.dataset.comp = card.dataset.compSlot;
|
||||
m.innerHTML = '<div class="shimmer"><span class="comp-note">rendering…</span></div><img class="comp" alt="" hidden>';
|
||||
const wireEl = front.querySelector('.media.wire');
|
||||
if (wireEl) { wireEl.hidden = true; front.insertBefore(m, wireEl); }
|
||||
else { front.classList.remove('text-only'); front.insertBefore(m, front.querySelector('.body')); }
|
||||
pollComp(m);
|
||||
});
|
||||
};
|
||||
const exitComp = () => {
|
||||
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
|
||||
const front = card.querySelector('.face.front');
|
||||
const pending = front?.querySelector('.media.comp-pending');
|
||||
if (!pending) return; // landed comps stay; they exist either way
|
||||
pending.remove();
|
||||
const wireEl = front.querySelector('.media.wire');
|
||||
if (wireEl) wireEl.hidden = false;
|
||||
else if (!front.querySelector('.media')) front.classList.add('text-only');
|
||||
});
|
||||
};
|
||||
const apply = (value) => {
|
||||
set(value);
|
||||
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
|
||||
if (value === 'comp') enterComp(); else exitComp();
|
||||
};
|
||||
// Flipping to comp starts real generation, so it confirms first; the
|
||||
// flip back is free and applies immediately.
|
||||
const confirm = document.getElementById('bp-confirm');
|
||||
const closeConfirm = () => { confirm.classList.remove('open'); confirm.hidden = true; };
|
||||
confirm.querySelector('[data-confirm]').addEventListener('click', () => { closeConfirm(); apply('comp'); });
|
||||
confirm.querySelector('[data-cancel]').addEventListener('click', closeConfirm);
|
||||
confirm.addEventListener('click', (e) => { if (e.target === confirm) closeConfirm(); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !confirm.hidden) closeConfirm(); });
|
||||
bp.querySelectorAll('.bp-opt').forEach(b => b.addEventListener('click', () => {
|
||||
const value = b.dataset.bp;
|
||||
if (value === current) return;
|
||||
if (value === 'comp') {
|
||||
confirm.hidden = false;
|
||||
requestAnimationFrame(() => confirm.classList.add('open'));
|
||||
return;
|
||||
}
|
||||
apply(value);
|
||||
}));
|
||||
}
|
||||
|
||||
// A declared image that never loads (missing catalog asset, offline shell)
|
||||
// must not sit as a dark void: the slot collapses to the card's own
|
||||
@@ -910,7 +1157,7 @@ function page() {
|
||||
// slots are excluded; their polling owns the wait.
|
||||
const artFailed = (img) => {
|
||||
const m = img.closest('.media');
|
||||
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
|
||||
if (!m || m.classList.contains('comp-pending') || m.classList.contains('unavailable')) return;
|
||||
m.classList.add('unavailable');
|
||||
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
|
||||
if (colors.length) m.style.background = 'linear-gradient(135deg, ' + colors.map((c, i) => c + ' ' + Math.round(i * 100 / colors.length) + '% ' + Math.round((i + 1) * 100 / colors.length) + '%').join(', ') + ')';
|
||||
@@ -923,7 +1170,7 @@ function page() {
|
||||
label.textContent = 'artwork unavailable';
|
||||
m.appendChild(label);
|
||||
};
|
||||
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
|
||||
document.querySelectorAll('.media:not(.comp-pending) > img').forEach(img => {
|
||||
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
||||
else img.addEventListener('error', () => artFailed(img), { once: true });
|
||||
});
|
||||
@@ -1109,6 +1356,26 @@ const server = http.createServer((req, res) => {
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/build-path') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end('{"ok":true}');
|
||||
let value = null;
|
||||
try { value = JSON.parse(body).value; } catch { /* ignore */ }
|
||||
if (value !== 'comp' && value !== 'code') return;
|
||||
const wasComp = liveBuildPath === 'comp';
|
||||
liveBuildPath = value;
|
||||
// Only a flip TO comp needs the agent mid-round: comps must start
|
||||
// rendering into the declared slots. The reverse is free.
|
||||
if (detachedKey && value === 'comp' && !wasComp) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/answer') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
@@ -1129,7 +1396,8 @@ const server = http.createServer((req, res) => {
|
||||
...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}),
|
||||
...(followupOpen ? { followup: true } : {}),
|
||||
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||
...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
|
||||
...((chosen?.comp ?? chosen?.sketch) ? { comp: chosen.comp ?? chosen.sketch } : {}),
|
||||
...(liveBuildPath && !isReroll ? { buildPath: liveBuildPath, buildPathFlipped: liveBuildPath !== (buildPathDefault?.value ?? null) } : {}),
|
||||
});
|
||||
if (detachedKey) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
|
||||
@@ -6,7 +6,7 @@ An open direction round owns the word first: "bolder" said while a direction dec
|
||||
|
||||
## Scope is sovereign
|
||||
|
||||
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, stop and ask the user directly to clarify what you cannot infer. before expanding it, naming the exact addition and the job it would do.
|
||||
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, do not expand it on your own. Ask the user directly to clarify what you cannot infer. Name the exact addition and the job it would do.
|
||||
|
||||
## Why it reads flat
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ Resolve one stable target, run two independent assessments, synthesize a design
|
||||
- Viewable targets require browser inspection when available.
|
||||
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
|
||||
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
|
||||
- The question is the LAST thing in the response. Write the entire report out first, then ask; nothing follows the question. Prose emitted after a structured question is withheld until the user answers it, so a report written after the question reads as if the critique never ran.
|
||||
- A run that ends with neither the targeted questions nor a literal `Questions skipped: <reason>` line is an incomplete run. The report is not the finish; the close is.
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -172,6 +174,14 @@ Provocative questions that might unlock better solutions:
|
||||
- Prioritize ruthlessly. If everything is important, nothing is.
|
||||
- Don't soften criticism. Developers need honest feedback to ship great design.
|
||||
|
||||
### Deliver the Report
|
||||
|
||||
Write the full report into the chat response now, before any persistence work. This is the deliverable; everything below it is bookkeeping.
|
||||
|
||||
Do this first because the alternative is the most common way this command fails: the report gets composed once, straight into the persistence heredoc, and the run ends with a perfect archive nobody has read. Composing it into a file is not delivering it. If the report exists only in `.impeccable/critique/`, the run produced nothing.
|
||||
|
||||
Persistence is not the end of the run. After it, the response continues with the trend line and the close.
|
||||
|
||||
### Persist the Snapshot
|
||||
|
||||
Once the report above is finalized, write it to `.impeccable/critique/` so the user can refer back, and so `/impeccable polish` can pick up the priority issues without a copy-paste.
|
||||
@@ -180,6 +190,8 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
|
||||
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, design-specificity verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
|
||||
|
||||
This is a copy of the report you already delivered above, for later commands to read. It is not delivery. If you find yourself composing the report for the first time inside this heredoc, you have skipped Deliver the Report; go back and send it.
|
||||
|
||||
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
|
||||
```bash
|
||||
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
|
||||
@@ -204,11 +216,15 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
|
||||
If this is the first run for the slug, the trend is just one score; say so: "First run for this target, no trend yet."
|
||||
|
||||
6. **Close the run.** Go to Ask the User below and emit the questions, or the `Questions skipped: <reason>` line when the count allows it. The run is not complete until you do. Persistence is bookkeeping and cleanup is not an ending; stopping here leaves the user with a report and no way forward, and leaves `/impeccable polish` with no priorities to inherit.
|
||||
|
||||
This is fire-and-forget. Do not show the user the helper's JSON output; only the human-readable trend line and the written path. Failures here should not block the rest of the flow; print the error and move on.
|
||||
|
||||
### Ask the User
|
||||
|
||||
**After presenting findings**, use targeted questions based on what was actually found. ask the user directly to clarify what you cannot infer. These answers will shape the action plan.
|
||||
**After presenting findings**, use targeted questions based on what was actually found. Ask the user directly to clarify what you cannot infer. These answers will shape the action plan.
|
||||
|
||||
Ask in the same message that carries the report, with the report written out first and the question last. Do not split the two across turns: a turn that ends on the report is a turn that ends, and the questions never arrive. Order within the message is what matters, because prose emitted after a structured question is withheld until the user answers.
|
||||
|
||||
Ask questions along these lines (adapt to the specific findings; do NOT ask generic questions):
|
||||
|
||||
@@ -224,7 +240,9 @@ Ask questions along these lines (adapt to the specific findings; do NOT ask gene
|
||||
- Every question must reference specific findings from the report. Never ask generic "who is your audience?" questions.
|
||||
- Keep it to 2-4 questions maximum. Respect the user's time.
|
||||
- Offer concrete options, not open-ended prompts.
|
||||
- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Recommended Actions.
|
||||
- Skipping is allowed only when the report listed **fewer than 3 Priority Issues**. Count them; do not judge the findings "straightforward" by feel. At 3 or more, the questions are required.
|
||||
|
||||
**Final-question gate.** The user-visible response must either include the targeted questions or carry the literal line `Questions skipped: <reason>` naming the count that permitted the skip. Each question must include 2-3 concrete answer options tied to the actual critique findings. Do not end with only open-ended questions, and do not end with neither: stopping after the report, having asked nothing and printed no skip line, is the most common way this command fails.
|
||||
|
||||
### Recommended Actions
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh
|
||||
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Analyze what makes the design feel complex or cluttered:
|
||||
- What can be removed, hidden, or combined?
|
||||
- What's the 20% that delivers 80% of value?
|
||||
|
||||
If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
|
||||
If any of these are unclear from the codebase, do not guess. Ask the user directly to clarify what you cannot infer.
|
||||
|
||||
**CRITICAL**: Simplicity is not about removing features. It's about removing obstacles between users and their goals. Every element should justify its existence.
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ The same restraint applies to `workspace-context-inherited`. Inheritance is a de
|
||||
|
||||
- `workspace-platform-native-evidence` is the finding that matters most here: a workspace carrying native build files while inheriting a root record that resolves to web gets web guidance for its whole life and never loads [ios.md](ios.md) or [android.md](android.md). The repair is a child PRODUCT.md in that workspace, because one inherited record cannot hold two platforms.
|
||||
- `config-project-roots-match-nothing` means every `projectRoots` glob missed, so the repo root is silently standing in as the active project. A renamed workspace directory is the usual cause. Report the patterns and ask which directories they should name.
|
||||
- `config-invalid-build-path` and `config-build-path-unset` both concern one key, `buildPath` in `.impeccable/config.json` (or the gitignored `.impeccable/config.local.json`, which wins for that developer). It holds `comp` or `code` and sets whether new surfaces are built from a generated comp or straight in code. An unread value does not fall back to the opposite path, so a project meaning `code` has been building comp-led; report the exact value. The unset finding fires only where a project has done direction work and never recorded a preference, and the offer belongs in it only when image generation exists in your tool surface. Without image generation there is nothing to choose and nothing to say.
|
||||
- Use the `workspaces` table to show the user which apps carry their own context, which inherit, and which have none, before proposing any change.
|
||||
|
||||
## Opting out of the boot check
|
||||
|
||||
@@ -68,7 +68,7 @@ Omit irrelevant sections rather than filling them with invented rules. Put respo
|
||||
- An existing `DESIGN.md` is stale (the design has drifted).
|
||||
- Before a large redesign, to capture the current state as a reference.
|
||||
|
||||
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file and ask the user directly to clarify what you cannot infer. whether to refresh, overwrite, or merge.
|
||||
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file first. Ask the user directly to clarify what you cannot infer. The choice is refresh, overwrite, or merge.
|
||||
|
||||
## Two paths
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Identify reusable patterns, components, and design tokens, then extract and cons
|
||||
|
||||
Find the design system, component library, or shared UI directory. Understand its structure: component organization, naming conventions, design token structure, import/export conventions.
|
||||
|
||||
**CRITICAL**: If no design system exists, ask the user directly to clarify what you cannot infer. before creating one. Understand the preferred location and structure first.
|
||||
**CRITICAL**: If no design system exists, do not create one yet. Ask the user directly to clarify what you cannot infer. Understand the preferred location and structure first.
|
||||
|
||||
## Step 2: Identify Patterns
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Form a platform hypothesis: `web`, `ios`, `android`, or `adaptive` (one product
|
||||
|
||||
## Step 3: Interview for product truth
|
||||
|
||||
ask the user directly to clarify what you cannot infer. Ask only about material gaps the repository and original request do not answer with strong evidence.
|
||||
Ask the user directly to clarify what you cannot infer. Ask only about material gaps the repository and original request do not answer with strong evidence.
|
||||
|
||||
Use the structured question tool when available; otherwise ask and wait. Keep rounds to at most three focused questions and require one real answer or approval round before writing a new PRODUCT.md. Confirm inferences.
|
||||
|
||||
@@ -107,9 +107,11 @@ When the platform you just recorded is `ios`, `android`, or `adaptive`, load [io
|
||||
|
||||
Before loading new-work or resuming shape/build, verify that PRODUCT.md exists at the resolved path and contains the confirmed product record. If the file is absent, init is incomplete. Do not substitute interview notes, a planning packet, or later design prose for the file.
|
||||
|
||||
## Step 5: Configure live mode when useful
|
||||
## Step 5: Record workflow defaults
|
||||
|
||||
Skip native or non-runnable projects and leave existing config untouched. Otherwise follow [live.md](live.md)'s first-time setup. Any CSP source edit still requires its stated consent.
|
||||
When image generation is available (context.mjs reports it) and no `buildPath` is recorded yet, ask once how new surfaces should be built, stated as the trade it is: **comp-first** (an image sets the bar before any code; bolder composition, slower, and the build must match the image) or **code-first** (build directly; the ambition is written into the direction contract and audited at the finish; leaner, faster). Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, merging with the keys already there. A value already recorded in `.impeccable/config.json` or the gitignored `.impeccable/config.local.json` is a confirmed answer: on a re-run, honor it in silence rather than asking again. This is a default, not a lock: the decision page renders a toggle whose flip binds a single session and is never written back. Without image generation there is no choice to record; code-first is the only path.
|
||||
|
||||
Then configure live mode when useful: skip native or non-runnable projects and leave existing config untouched. Otherwise follow [live.md](live.md)'s first-time setup. Any CSP source edit still requires its stated consent.
|
||||
|
||||
## Step 6: Wrap up or resume
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Keep the visual system fixed. Derive five to seven materially different structur
|
||||
|
||||
`node .gemini/skills/impeccable/scripts/concept-seed.mjs --scope surface --mode <mode>`
|
||||
|
||||
The script assigns which structure gets built; your top-ranked structure is what every run would ship, so the dice come from outside. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
||||
The script deals three of your structures to the table; the dice decide which three reach the user, so the ranking rut stays broken while the user still holds a real choice. Present the three dealt structures on the decision page as full cards of equal salience, the dealt lead carrying kicker THE ROLL, with steer and re-roll; the user locks one in. No canon card and no pick card at surface scope: the world is settled, so every card visualizes composition, not identity. With image generation available and a comp-led default (the build-path paragraph below: `.impeccable/config.json`, the toggle handles the exception), each card declares a `comp` under `.impeccable/mocks/decision/`, generated after serving in reading order under the comp discipline in [visualize.md](visualize.md); anchor each of these comps on the established identity by passing a captured screenshot of a representative existing page as a reference image (the harness image tool's input image, or `generate-image.mjs --ref`) beside a prompt that leads with the new surface's structure and names DESIGN.md's palette, type, and component character, because a prose paraphrase of a design system drifts where a pixel reference does not. Without image generation, or under a code-led default, each card instead carries a `wireframe` layout schematic (see `serve-question.mjs --schema`) that the page draws itself. Locking a card is the approval and sets the build path: a locked comp builds comp-led with that comp as the approved comp, discharging [visualize.md](visualize.md)'s three-option round with no second approval point; a locked wireframe builds code-led, its ambition carried by the direction contract. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
|
||||
|
||||
### Create or replace the visual world
|
||||
|
||||
@@ -44,13 +44,13 @@ 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, not only its physical objects: the notation, publications, identity programs, data graphics, and interfaces it reads daily; a nameable abstract system (a school of poster, a documentation standard) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; dig until the list spans at least three families.
|
||||
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
|
||||
4. Run `node .gemini/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point. The weighing closes with a verdict per challenger, decided before any borrowing is considered: wins (beats the assigned direction on both axes; it becomes the build candidate), competitive (holds one axis; it stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a motif lifted from a declined world is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker MY PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
|
||||
5. Present one direction, fully committed and already raised by the hand it beat, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
|
||||
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `followup: true` when the execution-contract round will follow (it does whenever image generation exists and no standing build-path preference is recorded); a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .gemini/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
|
||||
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (the build-path paragraph below owns the details); a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .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. The fallback to the structured question tool is never yours to predict: run the script, and only exit code 2 from starting it routes the decision there; treat that exit as the fallback, never as an error to retry.
|
||||
|
||||
When image generation exists, every card also declares a `sketch` path under `.impeccable/mocks/decision/` (the field keeps its wire name for compatibility; what it carries is the card's comp), the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished sketch pays sketch quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way; visualize.md's self-checks bind decision comps identically. Generation takes the same time at any fidelity, so an unfinished draft pays draft quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
|
||||
|
||||
The moment the direction lands, one more round on the same open table decides the execution contract. The direction payload declares `followup: true`, so the table stays open after the pick; deliver the build-path payload through `--update` immediately. Two text-only cards. **Comp-led**: a first-viewport comp is generated and it is law, the finish review audits the build against it; boldest composition on the table, fix rounds expected, motion at risk; choosing it makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. Lead with the chosen world's fit: a costume-heavy catalog world leads comp-led, a quiet or conventional direction leads code-led; the lead is a default, never a decision, and the user flips it freely. A standing preference, voiced once, is recorded as a brand commitment in PRODUCT.md and skips this round on later surfaces. Without image generation there is no fork and no round: code-led is the only path, stated in one line rather than asked. Only a detached table (`--start`) stays open for `--update`: a blocking serve or the structured-tool channel runs the build-path round as its own second question instead, and `followup: true` belongs only on a detached round.
|
||||
The execution contract, comp-led or code-led, is a workflow preference, not a per-surface decision, so no round asks it: the recorded default rides every round and the page's toggle handles the exception. Read the default from `.impeccable/config.json` (`buildPath`), with the gitignored `.impeccable/config.local.json` winning where one machine differs from the team's committed value; with neither, comp-led is the default whenever image generation exists. Author every direction and surface payload with `buildPath: { "value": <default>, "toggle": true }`; the page renders a footer toggle with the trade stated beside it, and the ANSWER returns `buildPath` plus `buildPathFlipped`. A flipped value binds that session only and is never written back, with one exception, and it is the only thing inside a round that earns a question about this preference (init records it up front on projects that get the chance): when `buildPathFlipped` comes back true on a project that records no `buildPath` at all, ask once after the round closes whether to keep it as the standing default. Either answer ends in a write to `.impeccable/config.json`; the answer picks the value, never whether to record one. Yes writes the flipped value, and "no, just this once" writes the value they flipped away from, which is the standing default they just confirmed by declining. Ask on the flip and never on the untouched default, because a user who left the toggle alone has told you nothing. A declined offer nothing writes down is an offer the next session makes again. When the user asks in words to change the standing default, update the file without asking. **Comp-led**: the chosen card's comp is law, generated before building when it does not exist yet, and the finish review audits the build against it; boldest composition on the table, fix rounds expected; comp-led makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. A code-led round still declares each card's comp path as a flip reserve: when the user flips the toggle to comp mid-round, `--wait` returns once with BUILD PATH FLIPPED while the page shimmers the slots; generate each open card's comp into its declared path then, lead first, and wait again. The flip back is free, and a comp that already rendered rides at the finish review as the critique reference. Without image generation there is no toggle and no choice: code-led is the only path, stated in one line rather than asked. The old two-card execution-contract round is retired; `followup: true` remains the general mechanism for delivering any later round over the same table via `--update`.
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Push an interface past conventional limits. This isn't just about visual effects
|
||||
This command has the highest potential to misfire. Do NOT jump straight into implementation. You MUST:
|
||||
|
||||
1. **Think through 2-3 different directions**: consider different techniques, levels of ambition, and aesthetic approaches. For each direction, briefly describe what the result would look and feel like.
|
||||
2. **ask the user directly to clarify what you cannot infer.** to present these directions and get the user's pick before writing any code. Explain trade-offs (browser support, performance cost, complexity).
|
||||
2. **Get the user's pick before writing any code.** Ask the user directly to clarify what you cannot infer. Carry each direction's description and its trade-offs (browser support, performance cost, complexity) inside the option itself, so the user is choosing between things they can read. A structured question blocks the message it rides in until the user answers, so directions written alongside the question stay invisible while the user is being asked to choose between them.
|
||||
3. Only proceed with the direction the user confirms.
|
||||
|
||||
Skipping this step risks building something embarrassing that needs to be thrown away.
|
||||
|
||||
@@ -28,7 +28,7 @@ Analyze what makes the design feel too intense:
|
||||
- What's working? (Don't throw away good ideas)
|
||||
- What's the core message? (Preserve what matters)
|
||||
|
||||
If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
|
||||
If any of these are unclear from the codebase, do not guess. Ask the user directly to clarify what you cannot infer.
|
||||
|
||||
**CRITICAL**: "Quieter" doesn't mean boring or generic. It means refined and easier on the eyes. Think luxury, not laziness.
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# Visualize: Direction Comps & Asset Production
|
||||
|
||||
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
|
||||
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it. A surface-scope structure round that already put three visualized cards before the user (new-work.md, established world) has discharged this round: the locked card’s comp is the approved comp, so record the approval and continue at After approval; generate nothing new.
|
||||
|
||||
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
|
||||
|
||||
## Generate three compositional options
|
||||
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here.
|
||||
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. On an established world, anchor every comp on the real identity: capture a screenshot of a representative existing page and pass it as a reference image (the harness image tool’s input image, or `generate-image.mjs --ref`); the prompt then leads with the new surface’s structure while the reference carries palette, type, and component character, because DESIGN.md words alone drift where a pixel reference does not. Name what the reference contributes and what it must not: chrome, palette, type, and component character carry over; the reference page’s own content does not, so a banner, hero, or card lifted verbatim from the reference is the reference leaking, not fidelity. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here.
|
||||
|
||||
- A comp is a designed surface, not a picture of the subject. Lead the generation prompt with the surface's own structure, whatever regions this design actually has, named in order with their scale relationships; a page with no navigation states that instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with the world's atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph or scene with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally.
|
||||
- The inverse is also a failure: a surface with none of its subject in it. The subject appears as the content the regions exist to hold; the world dresses the frame and never displaces what the frame exists to show. The deletion usually rides in on the prompt's exclusion list, so exclusions bind invented claims, and a medium ban belongs to the committed imagery stance, never to caution. Before accepting a render, point at the subject: a render that depicts everything about the world and nothing of the subject fails however faithful its atmosphere, so regenerate with the subject's content named region by region.
|
||||
- A comp is judged as the shipped screen: the visitor's job must be readable from the image alone. Name the surface's mode from the render with no caption; a render whose mode cannot be read back is art direction without a surface, so regenerate with the visitor's job as the prompt's spine.
|
||||
- Commitment is depth, not coverage. The world enters through one dominant move plus the material, type, and spacing that support it, and the remaining regions hold still so that move can be read; a region that simply does its job in the world's own grammar carries the direction further than a region performing the concept. The check cuts competition, never content: a quieted region keeps its information and stops performing. Where the direction names a focal moment, a second element competing with it at the same scale means the comp is shouting; where it names none, several regions performing the concept at once is the same shout. Regenerate keeping the strongest move and quieting the rest. Busy is louder, not bolder.
|
||||
- When the user shortlisted multiple concepts, spread the three across them.
|
||||
- When 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.
|
||||
@@ -18,7 +21,7 @@ Treat each comp as a direction test, not a screenshot specification. Core UI tex
|
||||
|
||||
## One approval point
|
||||
|
||||
Show the three together: in the harness when it can display images, otherwise on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero). Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
Show the three together on the decision page (`serve-question.mjs`, one option per comp with the comp as its hero), or in the harness only when it renders images inline; a text-only surface does not count as display. Ask what should carry forward, what feels false to the world, and whether the selected surface concept should be approved, combined, revised, or rejected. Then stop and wait. A structured simulated user counts as attended and receives the same question.
|
||||
|
||||
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
|
||||
|
||||
|
||||
@@ -338,6 +338,20 @@ export function renderConceptSeed({
|
||||
};
|
||||
const indexSalt = reroll === 0 ? 'index' : `index:reroll-${reroll}`;
|
||||
const buildIndex = 3 + Math.floor(unit(indexSalt) * (candidateCount - 2)); // 3..candidateCount
|
||||
// Surface scope deals a hand of three grounded structures: one card is not
|
||||
// a choice, and the full ranked list would hand selection back to the
|
||||
// model's taste. The dice pick all three; the primary index leads. The
|
||||
// no-lineup rule stays direction-only, where it was written for worlds.
|
||||
const dealtIndices = [buildIndex];
|
||||
for (let draw = 0; scope === 'surface' && dealtIndices.length < Math.min(3, candidateCount); draw += 1) {
|
||||
const idx = 1 + Math.floor(unit(`${indexSalt}:deal-${draw}`) * candidateCount);
|
||||
if (!dealtIndices.includes(idx)) dealtIndices.push(idx);
|
||||
if (draw > 64) { // hash repeats cannot stall the deal
|
||||
for (let fill = 1; dealtIndices.length < Math.min(3, candidateCount); fill += 1) {
|
||||
if (!dealtIndices.includes(fill)) dealtIndices.push(fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local catalog first (private repo, evals, tests), then the roll API,
|
||||
// then a degraded assignment-only seed. The assigned index is pure local
|
||||
@@ -404,19 +418,31 @@ export function renderConceptSeed({
|
||||
interaction and state, and a substantially different future surface. In an
|
||||
attended run, present the assigned direction fully committed and offer
|
||||
re-roll. You may add ONE card for your top-ranked grounded candidate when
|
||||
it is not the assigned direction, kicker MY PICK, with an honest risk line
|
||||
it is not the assigned direction, kicker IMPECCABLE’S PICK, with an honest risk line
|
||||
naming its familiarity; one pick card, never a ranked lineup, and the pick
|
||||
never takes the lead position. When the assignment IS your top candidate,
|
||||
there is no pick card. Re-roll yourself only
|
||||
on named factual grounds, when the assignment cannot carry the product's
|
||||
truth or task; taste is never grounds.`
|
||||
: `After ordering the task's grounded structural candidates by resonance,
|
||||
build candidate ${buildIndex} of your own grounded list; the assignment never
|
||||
points at a challenger. The assignment is the roll, not a suggestion.
|
||||
In an attended run, present the assigned structure and offer re-roll; never
|
||||
present a ranked lineup to choose from. Re-roll yourself only when the
|
||||
assignment fails audience identification or product clarity on named
|
||||
factual grounds.`;
|
||||
deal candidates ${dealtIndices.join(', ')} of your own grounded list to the
|
||||
table; index ${buildIndex} leads, and the deal never points at a challenger.
|
||||
The deal is the roll, not a suggestion: the dice decide which structures
|
||||
reach the user, so the ranking rut stays broken while the user still gets a
|
||||
real choice, and the full ranked list stays yours. In an attended run,
|
||||
present the three dealt structures as full cards of equal salience, the
|
||||
lead carrying kicker THE ROLL, with steer and re-roll, and let the user
|
||||
lock one in; the world is already settled, so this choice is composition.
|
||||
Visualize every dealt card: with image generation available and a
|
||||
comp-led default (.impeccable/config.json buildPath; the page toggle
|
||||
handles the exception), declare a comp per card and generate after
|
||||
serving, lead first; otherwise author each card's wireframe field (see
|
||||
serve-question --schema) and the page draws the schematic. Carry the
|
||||
recorded default in the payload as buildPath with toggle: true. Locking a card
|
||||
approves its comp: a surface round that put three visualized structures on
|
||||
the table replaces the three-option comp round in visualize.md. Re-roll
|
||||
yourself only when every dealt structure fails audience identification or
|
||||
product clarity on named factual grounds.`;
|
||||
|
||||
const challengerInstruction = scope === 'direction'
|
||||
? `Fuse each challenger before judging it: the challenger supplies the form
|
||||
@@ -490,7 +516,7 @@ assigned index is suspended this round and the user picks; seed key ${key}.
|
||||
`
|
||||
: '';
|
||||
return `${degradedHeader}
|
||||
${degradedRegister}ASSIGNED INDEX: ${buildIndex}
|
||||
${degradedRegister}${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
|
||||
${promotedInstruction}
|
||||
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.
|
||||
@@ -514,8 +540,11 @@ channel: when a browser can open, present the direction on the decision page
|
||||
the no-browser fallback.
|
||||
${authorityInstruction}
|
||||
A user- or brief-pinned decision beats the roll, always.
|
||||
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.
|
||||
${scope === 'direction'
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||
: `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index
|
||||
${buildIndex} leads. Present all three dealt structures; seed key ${key}.`}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -595,7 +624,7 @@ rivals to your habitual layout, and keep only what makes this product clearer.${
|
||||
or IMPECCABLE_NO_TELEMETRY is set.\n`
|
||||
: '';
|
||||
const assignedBlock = register === null
|
||||
? `ASSIGNED INDEX: ${buildIndex}
|
||||
? `${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
|
||||
${promotedInstruction}
|
||||
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.`
|
||||
@@ -621,8 +650,11 @@ craft bar, the finish level and commitment the build is expected to reach,
|
||||
never as a mockup to copy; your surface serves this product, not that render.
|
||||
`;
|
||||
const restated = register === null
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
? (scope === 'direction'
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||
: `DEALT INDICES (restated for truncated readers): ${dealtIndices.join(', ')}; index
|
||||
${buildIndex} leads. Present all three dealt structures; seed key ${key}.`)
|
||||
: `REGISTER (restated for truncated readers): ${register}, user-requested; the
|
||||
assigned index is suspended this round; seed key ${key}.`;
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
|
||||
|
||||
@@ -42,14 +42,15 @@ function latestCritique(cwd) {
|
||||
if (!latest) return null;
|
||||
const get = (key) => latest.meta[key] ?? null;
|
||||
const num = (v) => {
|
||||
if (v == null || (typeof v === 'string' && v.trim() === '')) return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
return {
|
||||
slug: get('slug'),
|
||||
score: num(get('score')),
|
||||
p0: num(get('p0')),
|
||||
p1: num(get('p1')),
|
||||
score: num(get('total_score') ?? get('score')),
|
||||
p0: num(get('p0_count') ?? get('p0')),
|
||||
p1: num(get('p1_count') ?? get('p1')),
|
||||
timestamp: get('timestamp'),
|
||||
file: path.relative(cwd, latest.path),
|
||||
};
|
||||
|
||||
@@ -1013,14 +1013,22 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
// still spelled out as the next command. The offer stays; the command leaves
|
||||
// this turn entirely, because installing over the skill mid-session changes
|
||||
// files the session is reading and only takes effect in the next one anyway.
|
||||
function buildUpdateDirective(localVersion, latestVersion) {
|
||||
return (
|
||||
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
|
||||
`(installed v${localVersion}, latest v${latestVersion}). ` +
|
||||
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
|
||||
`Mention it once, in this form: "A newer Impeccable (v${latestVersion}) is available. ` +
|
||||
`Update now? It runs \`npx impeccable update\`." ` +
|
||||
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
|
||||
`Either way, continue the current task without waiting, and do not raise this again.`
|
||||
`Do not run \`npx impeccable update\` in this turn, whatever the user answers: it rewrites the skill files ` +
|
||||
`this session is reading, and the update only takes effect in the next session, so there is nothing to gain now. ` +
|
||||
`Run it in a later turn, only after the user has asked for it in their own words. ` +
|
||||
`Continue the current task now without waiting, and do not raise this again.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1142,6 +1150,7 @@ async function cli() {
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
appendDetectorFallback(parts, ctx);
|
||||
appendImageGenDirective(parts);
|
||||
appendBuildPathDirective(parts, ctx);
|
||||
appendAutonomyCounterDirective(parts);
|
||||
appendSubagentAuthorizationDirective(parts);
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
@@ -1161,6 +1170,7 @@ async function cli() {
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
appendDetectorFallback(parts, ctx);
|
||||
appendImageGenDirective(parts);
|
||||
appendBuildPathDirective(parts, ctx);
|
||||
appendAutonomyCounterDirective(parts);
|
||||
appendSubagentAuthorizationDirective(parts);
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
@@ -1269,6 +1279,53 @@ function automaticHookMode(ctx) {
|
||||
}
|
||||
|
||||
|
||||
// Build-path preference: a workflow setting (comp-led vs code-led), read here
|
||||
// so every session starts knowing it without a file hunt. It rides the unified
|
||||
// config beside the hook and detector settings, and the gitignored local file
|
||||
// wins, because whether a machine has an image tool is a property of that
|
||||
// machine, not of the team's committed default. Absence stays silent;
|
||||
// new-work's own default applies, and the decision page toggle can flip the
|
||||
// value for a single session.
|
||||
function readBuildPathAt(root) {
|
||||
let value = null;
|
||||
let source = null;
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = readJson(path.join(root, '.impeccable', name));
|
||||
if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') {
|
||||
value = raw.buildPath;
|
||||
source = `.impeccable/${name}`;
|
||||
}
|
||||
}
|
||||
return value ? { value, source } : null;
|
||||
}
|
||||
|
||||
// Roots in precedence order, nearest first: the resolved project decides, and
|
||||
// the repo root is the fallback a monorepo commits once for every app in it.
|
||||
// `checkBuildPathUnset` reads exactly these two, and the pair has to match:
|
||||
// when they disagree the finding goes silent because a value exists while the
|
||||
// directive never names it, which is the one combination nobody can debug.
|
||||
//
|
||||
// The invoking directory is deliberately not in the chain. With `--target`
|
||||
// selecting another workspace, cwd is the caller's app, not the target's, and
|
||||
// letting it rank above the repo root hands one workspace another's workflow.
|
||||
// It stands in only when no project resolved at all.
|
||||
function appendBuildPathDirective(parts, ctx) {
|
||||
const roots = [...new Set(
|
||||
[ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((root) => path.resolve(root)),
|
||||
)];
|
||||
for (const root of roots) {
|
||||
const found = readBuildPathAt(root);
|
||||
if (!found) continue;
|
||||
// "Never written back" is scoped by the fact that this directive exists at
|
||||
// all: it is emitted only where a value is already recorded, which is the
|
||||
// case where a flip really is session-only. Saying so inline because the
|
||||
// bare absolute reads as a rule that overrides new-work's one-time offer,
|
||||
// which is exactly how the same wording misfired in serve-question.
|
||||
parts.push(`BUILD_PATH_DEFAULT: ${found.value} (from ${found.source}). Author direction and surface rounds with this as buildPath.value and toggle: true; a flip on the page binds that session only and is never written back, because a default is already recorded here. New-work's one-time offer to record a flipped value applies only where no default exists, which is why you are not seeing this line on those projects.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Image generation availability: harness-native tools always win, but when the
|
||||
// environment carries an OpenAI key the API fallback works everywhere. The
|
||||
// flag only reports capability, positively: absence stays silent, because a
|
||||
|
||||
@@ -14,6 +14,10 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
// reuse the r/g/b-only channel tolerance.
|
||||
const SHADOW_ALPHA_TOLERANCE = 0.02;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
||||
@@ -474,6 +478,25 @@ function addSidecarRadii(out, sidecar) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sidecar `extensions.shadows` entries ({ name, value, purpose }) carry the
|
||||
// documented shadow vocabulary that Stitch's frontmatter schema can't hold.
|
||||
// Their colors go into a separate allowlist — NOT allowedColorKeys — because a
|
||||
// shadow black is only documented *as a shadow*: feeding it into the general
|
||||
// color allowlist would legalize #000 as a page ground (alpha is dropped from
|
||||
// colorKey), which is the hole issue #547 warns against.
|
||||
function addSidecarShadows(out, sidecar) {
|
||||
const shadows = sidecar?.extensions?.shadows;
|
||||
if (!Array.isArray(shadows)) return;
|
||||
|
||||
for (const entry of shadows) {
|
||||
if (typeof entry?.value !== 'string') continue;
|
||||
for (const match of entry.value.matchAll(CSS_COLOR_RE)) {
|
||||
const parsed = parseDesignColor(match[0]);
|
||||
if (parsed) out.allowedShadowColors.push({ color: parsed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDesignSystem(input = {}) {
|
||||
const frontmatter = input.frontmatter || {};
|
||||
const sidecar = input.sidecar || null;
|
||||
@@ -486,6 +509,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
allowedColorKeys: new Map(),
|
||||
allowedRadii: [],
|
||||
allowedFontSizes: [],
|
||||
allowedShadowColors: [],
|
||||
hasPillRadius: false,
|
||||
};
|
||||
|
||||
@@ -495,6 +519,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
addSidecarColors(out, sidecar);
|
||||
addRoundedScale(out, frontmatter.rounded);
|
||||
addSidecarRadii(out, sidecar);
|
||||
addSidecarShadows(out, sidecar);
|
||||
|
||||
out.hasFonts = out.allowedFonts.size > 0;
|
||||
out.hasColors = out.allowedColorKeys.size > 0;
|
||||
@@ -614,6 +639,20 @@ function isAllowedColorRaw(raw, designSystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A color is a documented shadow color only when both the r/g/b channels AND
|
||||
// the alpha match a sidecar shadow token's color. Alpha has to be compared
|
||||
// here because colorKey()/colorsClose() drop it, and a match on r/g/b alone
|
||||
// would let every black at every alpha through.
|
||||
function isAllowedShadowColorRaw(raw, designSystem) {
|
||||
if (!designSystem?.allowedShadowColors?.length) return false;
|
||||
const parsed = parseDesignColor(String(raw || '').trim().toLowerCase());
|
||||
if (!parsed) return false;
|
||||
return designSystem.allowedShadowColors.some(entry =>
|
||||
colorsClose(parsed, entry.color) &&
|
||||
Math.abs((parsed.a ?? 1) - (entry.color.a ?? 1)) <= SHADOW_ALPHA_TOLERANCE,
|
||||
);
|
||||
}
|
||||
|
||||
function isAllowedRadiusRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasRadii) return true;
|
||||
const text = String(raw || '').trim().toLowerCase();
|
||||
@@ -691,6 +730,40 @@ function isProbablyColorLiteral(line, match) {
|
||||
return styleContext || cssFunctionContext || jsColorKeyContext;
|
||||
}
|
||||
|
||||
// One complete `${...}` template interpolation. Its content may carry paired
|
||||
// quoted strings (function arguments, ternary branches) and one level of
|
||||
// braces (an object-literal argument, itself allowing paired quotes). Deeper
|
||||
// nesting would need a parser, so the regex deliberately fails safe there:
|
||||
// the context check misses and the finding fires — a false positive a waiver
|
||||
// can silence, never a leak.
|
||||
const QUOTED_STRING_SRC = `"[^"]*"|'[^']*'`;
|
||||
const INTERPOLATION_SRC =
|
||||
`\\$\\{(?:${QUOTED_STRING_SRC}|\\{(?:${QUOTED_STRING_SRC}|[^{}"'\`])*\\}|[^{}"'\`])*\\}`;
|
||||
// The two shadow-context tails. Unlike jsColorKeyContext, the JS tail admits
|
||||
// commas: a multi-layer shadow string is comma-separated, and a later
|
||||
// property on the same line is still blocked because it sits past the
|
||||
// string's closing quote. Both tails admit complete interpolations; a bare
|
||||
// `}`, quote, or `;` still ends the context.
|
||||
const SHADOW_CSS_CONTEXT_RE = new RegExp(
|
||||
`(?:^|[{\\s;"'\`(,])(?:box-shadow|text-shadow)\\s*:\\s*(?:${INTERPOLATION_SRC}|[^;{}"'\`])*$`, 'i',
|
||||
);
|
||||
const SHADOW_JS_CONTEXT_RE = new RegExp(
|
||||
`(?:^|[,{]\\s*)(?:boxShadow|textShadow)\\s*[:=]\\s*["'\`]?(?:${INTERPOLATION_SRC}|[^"'\`}])*$`, 'i',
|
||||
);
|
||||
|
||||
// True when the color literal sits inside a box-shadow / text-shadow value —
|
||||
// the only contexts where a documented shadow color is legal. Anchored to the
|
||||
// end of `before` (no ; } { or quote in between) so a shadow property earlier
|
||||
// on the line can't leak the allowance into a later declaration. Kept separate
|
||||
// from isProbablyColorLiteral(), which stays a boolean for its existing call
|
||||
// sites and deliberately discards which property matched.
|
||||
function isShadowPropertyContext(line, match) {
|
||||
const index = match.index ?? -1;
|
||||
if (index < 0) return false;
|
||||
const before = line.slice(0, index);
|
||||
return SHADOW_CSS_CONTEXT_RE.test(before) || SHADOW_JS_CONTEXT_RE.test(before);
|
||||
}
|
||||
|
||||
function isInsideCssAttributeSelector(line, index) {
|
||||
if (index < 0) return false;
|
||||
const before = line.slice(0, index);
|
||||
@@ -824,6 +897,7 @@ function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
if (!isProbablyColorLiteral(line, match)) continue;
|
||||
const raw = cssColorLabel(match[0]);
|
||||
if (isAllowedColorRaw(raw, designSystem)) continue;
|
||||
if (isShadowPropertyContext(line, match) && isAllowedShadowColorRaw(raw, designSystem)) continue;
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-color',
|
||||
filePath,
|
||||
@@ -1038,6 +1112,7 @@ export {
|
||||
loadDesignSystemForCwd,
|
||||
isAllowedFont,
|
||||
isAllowedColorRaw,
|
||||
isAllowedShadowColorRaw,
|
||||
isAllowedRadiusRaw,
|
||||
isAllowedFontSizeRaw,
|
||||
checkSourceDesignSystem,
|
||||
|
||||
@@ -425,25 +425,28 @@ const REGEX_MATCHERS = [
|
||||
},
|
||||
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
|
||||
// --- Layout property transition ---
|
||||
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
|
||||
// JSX inline style objects use comma-delimited quoted values, not semicolons (issue #548).
|
||||
{ id: 'layout-transition', regex: /transition\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi,
|
||||
test: (m) => {
|
||||
const val = m[1].toLowerCase();
|
||||
const val = (m[2] ?? m[3] ?? '').toLowerCase();
|
||||
if (/\ball\b/.test(val)) return false;
|
||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||
},
|
||||
fmt: (m) => {
|
||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
|
||||
const raw = m[2] ?? m[3] ?? '';
|
||||
const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition: ${found ? found.join(', ') : raw.trim()}`;
|
||||
} },
|
||||
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
|
||||
{ id: 'layout-transition', regex: /transition-property\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi,
|
||||
test: (m) => {
|
||||
const val = m[1].toLowerCase();
|
||||
const val = (m[2] ?? m[3] ?? '').toLowerCase();
|
||||
if (/\ball\b/.test(val)) return false;
|
||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||
},
|
||||
fmt: (m) => {
|
||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
|
||||
const raw = m[2] ?? m[3] ?? '';
|
||||
const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition-property: ${found ? found.join(', ') : raw.trim()}`;
|
||||
} },
|
||||
// --- Broken image: src="" or src="#" or src=" " ---
|
||||
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkBuildPathUnset,
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
checkNativePlatformEvidence,
|
||||
@@ -120,6 +121,7 @@ async function collect(cwd, targetOptions) {
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...checkHookInstallation({
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
*
|
||||
* node generate-image.mjs --prompt "..." --out mock.png [--size 1536x1024] [--quality medium]
|
||||
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
|
||||
* node generate-image.mjs --prompt "..." --out mock.png --ref screenshot.png [--ref more.png]
|
||||
*
|
||||
* --ref anchors generation on input image(s) via the edits endpoint: pass a
|
||||
* captured screenshot of a representative existing page when comping a new
|
||||
* surface for an established world, so the identity comes from the real UI.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import zlib from 'node:zlib';
|
||||
@@ -212,12 +217,44 @@ if (!prompt || !out) {
|
||||
}
|
||||
const size = arg('size', '1536x1024');
|
||||
const quality = arg('quality', 'medium');
|
||||
// Reference images (--ref, repeatable): route through the edits endpoint,
|
||||
// which accepts input images. This is how a comp for an established world
|
||||
// inherits the real UI's identity from a captured screenshot instead of a
|
||||
// prose paraphrase of it; the prompt then describes the NEW surface and the
|
||||
// reference carries palette, type, and component character.
|
||||
const refs = (() => {
|
||||
const found = [];
|
||||
for (let i = 0; i < process.argv.length; i += 1) {
|
||||
if (process.argv[i] === '--ref' && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) found.push(process.argv[i + 1]);
|
||||
}
|
||||
return found;
|
||||
})();
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/images/generations', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
|
||||
});
|
||||
let response;
|
||||
if (refs.length) {
|
||||
const form = new FormData();
|
||||
form.append('model', 'gpt-image-2');
|
||||
form.append('prompt', prompt);
|
||||
form.append('size', size);
|
||||
form.append('quality', quality);
|
||||
form.append('n', '1');
|
||||
for (const ref of refs) {
|
||||
const bytes = fs.readFileSync(ref);
|
||||
const type = ref.endsWith('.png') ? 'image/png' : ref.endsWith('.webp') ? 'image/webp' : 'image/jpeg';
|
||||
form.append('image[]', new Blob([bytes], { type }), ref.split('/').pop());
|
||||
}
|
||||
response = await fetch('https://api.openai.com/v1/images/edits', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
body: form,
|
||||
});
|
||||
} else {
|
||||
response = await fetch('https://api.openai.com/v1/images/generations', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error(`generate-image: API error ${response.status}: ${(await response.text()).slice(0, 300)}`);
|
||||
process.exit(1);
|
||||
@@ -235,6 +272,6 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
|
||||
try {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2' }, null, 2));
|
||||
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
|
||||
} catch { /* embedding is best-effort */ }
|
||||
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
|
||||
|
||||
@@ -47,18 +47,33 @@ import {
|
||||
|
||||
// Top-level keys any reader honors: `hook` and `detector` subtrees (hook-lib's
|
||||
// readConfig), `updateCheck` (context.mjs), `projectRoots` (context.mjs's
|
||||
// monorepo resolution), plus `stalenessCheck` below. `$schema` and `version`
|
||||
// are allowed as conventional metadata nobody reads.
|
||||
// monorepo resolution), `buildPath` (context.mjs's build-path directive), plus
|
||||
// `stalenessCheck` below. `$schema` and `version` are allowed as conventional
|
||||
// metadata nobody reads.
|
||||
const KNOWN_CONFIG_KEYS = new Set([
|
||||
'hook',
|
||||
'detector',
|
||||
'updateCheck',
|
||||
'stalenessCheck',
|
||||
'projectRoots',
|
||||
'buildPath',
|
||||
'$schema',
|
||||
'version',
|
||||
]);
|
||||
|
||||
// The only two values context.mjs and new-work honor. A near miss reads as a
|
||||
// working preference and silently rides the opposite path, so it is worth
|
||||
// reporting rather than coercing.
|
||||
const BUILD_PATH_VALUES = Object.freeze(['comp', 'code']);
|
||||
|
||||
// Evidence that this project does the kind of work `buildPath` governs. A
|
||||
// project that only ever ran polish or audit has no use for the setting and
|
||||
// should never be told it exists. Two stats, so Tier 1 can afford it.
|
||||
const DIRECTION_WORK_PATHS = Object.freeze([
|
||||
path.join('.impeccable', 'surfaces'),
|
||||
path.join('.impeccable', 'mocks', 'decision'),
|
||||
]);
|
||||
|
||||
// `detector` is a closed set, so a typo here is worth reporting. `hook` is not
|
||||
// checked: it carries runtime settings from several writers and the false
|
||||
// positive rate would outweigh the catch.
|
||||
@@ -325,6 +340,20 @@ export function checkConfig({ projectRoot, repoRoot }) {
|
||||
}));
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'buildPath')
|
||||
&& !BUILD_PATH_VALUES.includes(raw.buildPath)) {
|
||||
findings.push(finding({
|
||||
id: 'config-invalid-build-path',
|
||||
artifact: 'config.json',
|
||||
filePath: rel,
|
||||
severity: 'mention',
|
||||
summary: `${rel} sets \`buildPath\` to ${JSON.stringify(raw.buildPath)}, which nothing reads. `
|
||||
+ `The values are ${BUILD_PATH_VALUES.map((value) => `\`${value}\``).join(' and ')}.`,
|
||||
fix: 'Report the value. An unread `buildPath` does not fall back to the other path; '
|
||||
+ 'it falls back to the default, so a project meaning `code` has been building comp-led.',
|
||||
}));
|
||||
}
|
||||
|
||||
const detector = raw.detector;
|
||||
if (detector && typeof detector === 'object' && !Array.isArray(detector)) {
|
||||
const unknownDetector = Object.keys(detector).filter((key) => !KNOWN_DETECTOR_KEYS.has(key));
|
||||
@@ -345,6 +374,47 @@ export function checkConfig({ projectRoot, repoRoot }) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* No recorded build-path preference on a project that plainly does visual
|
||||
* direction work. Not drift in the usual sense: the setting is newer than the
|
||||
* project, so every project that predates it lands here at once. That is why
|
||||
* it is gated twice, on a product record and on evidence of the work the
|
||||
* setting governs, and why it says the choice rather than assuming a harness
|
||||
* can make it. Image generation is the real precondition and this module
|
||||
* cannot see it: a harness-native image tool leaves no trace on disk, so the
|
||||
* finding hands the question to the one reader that knows.
|
||||
*/
|
||||
export function checkBuildPathUnset({ projectRoot, repoRoot, product }) {
|
||||
if (!projectRoot || !product) return [];
|
||||
const roots = [...new Set([projectRoot, repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
||||
|
||||
for (const root of roots) {
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = readJson(path.join(root, '.impeccable', name));
|
||||
// Any declared value ends this, valid or not: an invalid one already has
|
||||
// its own finding and two reports of one key is noise.
|
||||
if (raw && Object.prototype.hasOwnProperty.call(raw, 'buildPath')) return [];
|
||||
}
|
||||
}
|
||||
|
||||
const evidence = DIRECTION_WORK_PATHS.filter((rel) => fs.existsSync(path.join(projectRoot, rel)));
|
||||
if (!evidence.length) return [];
|
||||
|
||||
return [finding({
|
||||
id: 'config-build-path-unset',
|
||||
artifact: 'config.json',
|
||||
filePath: '.impeccable/config.json',
|
||||
severity: 'mention',
|
||||
summary: 'This project has run visual direction work but records no `buildPath`, '
|
||||
+ 'so every direction round takes the comp-first default without anyone having chosen it.',
|
||||
fix: 'Only when image generation exists in your tool surface, offer the choice once: '
|
||||
+ '**comp-first** (an image sets the bar before any code; bolder composition, slower) or '
|
||||
+ '**code-first** (build directly; ambition carried by the direction contract; leaner, faster). '
|
||||
+ 'Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, '
|
||||
+ 'merging with the keys already there. Without image generation there is no choice to record: stay silent.',
|
||||
})];
|
||||
}
|
||||
|
||||
// ─── Surface briefs ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -446,6 +516,7 @@ export function collectBootFindings(ctx, extras = {}) {
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
|
||||
@@ -170,51 +170,35 @@ Output (JSON):
|
||||
}
|
||||
|
||||
if (svelteComponentManifest) {
|
||||
if (isDiscard) {
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
||||
'discard:' + id,
|
||||
() => {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
return { handled: true };
|
||||
},
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err);
|
||||
}
|
||||
emitResult({
|
||||
...result,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
carbonize: false,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
||||
'accept:' + id,
|
||||
() => inlineSvelteComponentAccept(
|
||||
const { sourceFile, componentDir } = svelteComponentManifest;
|
||||
const resultContext = {
|
||||
file: sourceFile,
|
||||
...(isDiscard ? { carbonize: false } : { sourceFile }),
|
||||
previewMode: 'svelte-component',
|
||||
componentDir,
|
||||
};
|
||||
const runOperation = isDiscard
|
||||
? () => {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
return { handled: true, ...resultContext };
|
||||
}
|
||||
: () => inlineSvelteComponentAccept(
|
||||
svelteComponentManifest,
|
||||
variantNum,
|
||||
paramValues,
|
||||
process.cwd(),
|
||||
),
|
||||
);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), sourceFile),
|
||||
requestedOperation + ':' + id,
|
||||
runOperation,
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err, {
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
sourceFile: svelteComponentManifest.sourceFile,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
});
|
||||
result = operationFailure(err, resultContext);
|
||||
}
|
||||
if (result.carbonize) {
|
||||
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
|
||||
|
||||
@@ -22,6 +22,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
// All known harness directories
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
|
||||
'.hermes',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
|
||||
];
|
||||
|
||||
|
||||
@@ -42,14 +42,14 @@
|
||||
* // raise lines under the identity row
|
||||
* "risk": "one line: the honest risk", // optional
|
||||
* "body": "fallback prose when the structured fields are absent",
|
||||
* "sketch": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
|
||||
* // full-fidelity direction comp (the field
|
||||
* // keeps the sketch era's wire name). May not
|
||||
* // exist yet: the page shimmer-waits and
|
||||
* "comp": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
|
||||
* // full-fidelity direction comp (the legacy
|
||||
* // key "sketch" is accepted as an alias). May
|
||||
* // not exist yet: the page shimmer-waits and
|
||||
* // polls the slot until the file lands, so
|
||||
* // serve first and generate after
|
||||
* "hero": "https://... or /abs/path.webp", // optional inspiration image;
|
||||
* // rides picture-in-picture when a sketch exists
|
||||
* // rides picture-in-picture when a comp exists
|
||||
* "board": "https://... or /abs/path.webp" // optional secondary image
|
||||
* }, ...
|
||||
* ],
|
||||
@@ -61,7 +61,7 @@
|
||||
* "canon": true, // adds the "Play it straight" standing exit;
|
||||
* // direction rounds only (returns {"optionId":"canon"})
|
||||
* "canonCard": { ... }, // optional: the standing exit as a full card with the
|
||||
* // same anatomy (label, thesis, palette, sketch, ...);
|
||||
* // same anatomy (label, thesis, palette, comp, ...);
|
||||
* // rendered last and visually subordinate. Without it,
|
||||
* // canon stays a quiet footer action.
|
||||
* "steer": true, // adds a free-text steer field returned with any answer
|
||||
@@ -75,7 +75,7 @@
|
||||
* // then the execution contract.
|
||||
* }
|
||||
*
|
||||
* Options render as large cards: the sketch leads when present, with the
|
||||
* Options render as large cards: the comp leads when present, with the
|
||||
* inspiration image picture-in-picture; a hero alone renders full-bleed; a
|
||||
* text-only direction gets its identity from the palette chips and tags.
|
||||
* Local image paths are served by this server; nothing is uploaded anywhere.
|
||||
@@ -148,7 +148,7 @@ function printAnswer(raw) {
|
||||
if (a.hero || a.board) {
|
||||
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code. When your harness only reads files, or runs sandboxed, download them INTO the workspace and open the relative path; a sandboxed viewer rejects absolute paths outside it. They set the craft bar the build must reach.");
|
||||
}
|
||||
if (a.sketch) {
|
||||
if (a.comp) {
|
||||
console.log('CHOSEN COMP: the decision comp at that path is compositional option one. On a comp-led build the comp round adds two variations beside it; on a code-led build it returns at the finish review as the critique reference. Never regenerate it from scratch.');
|
||||
}
|
||||
if (a.optionId === 'canon') {
|
||||
@@ -160,6 +160,18 @@ function printAnswer(raw) {
|
||||
if (a.followup && a.optionId !== 'reroll') {
|
||||
console.log('FOLLOWUP OPEN: the table stays open and the page is showing a loading hand. Deliver the next round now with --update --key <key> --payload <file>, then collect it with --wait; never leave the page waiting on a round you have not sent.');
|
||||
}
|
||||
if (a.buildPath === 'comp' || a.buildPath === 'code') {
|
||||
// The page never writes the flip itself, but "never write it" overstated
|
||||
// that into a rule the agent then applied to new-work's one-time offer,
|
||||
// which exists for exactly this case: a flip on a project that had no
|
||||
// recorded default is the only moment the preference is ever asked for.
|
||||
const origin = a.buildPathFlipped
|
||||
? 'flipped on the page, so it binds this session only, and the page never writes it back; the sole exception is new-work’s one-time offer, on a project that had no recorded default at all, which asks after the round closes and writes the answer to .impeccable/config.json'
|
||||
: 'the round’s recorded default';
|
||||
console.log(`BUILD PATH: ${a.buildPath} (${origin}). ${a.buildPath === 'comp'
|
||||
? 'Comp-led: the chosen card’s comp is law; generate it before building when it does not exist yet, and the finish review audits the build against it.'
|
||||
: 'Code-led: no comp is owed; a comp that already rendered rides at the finish review as the critique reference, and the ambition lives in the direction contract.'}`);
|
||||
}
|
||||
} catch { /* raw answer */ }
|
||||
}
|
||||
|
||||
@@ -169,23 +181,29 @@ const portArg = Number(arg('port', '0'));
|
||||
const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions');
|
||||
const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`);
|
||||
const answerFile = (key) => path.join(QUESTION_DIR, `${key}.answer.json`);
|
||||
// A code-to-comp flip mid-round: the page records it here and --wait
|
||||
// surfaces it as its own event, because the agent must start generating
|
||||
// comps while the round is still open. Comp-to-code needs no event; it is
|
||||
// free and rides the final ANSWER.
|
||||
const flipFile = (key) => path.join(QUESTION_DIR, `${key}.flip.json`);
|
||||
|
||||
if (hasFlag('schema')) {
|
||||
console.log(JSON.stringify({
|
||||
title: 'Choose the visual world',
|
||||
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
|
||||
options: [
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], sketch: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
|
||||
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'MY PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as today’s ballad.', risk: 'Also the direction most runs in this category land on.', sketch: '.impeccable/mocks/decision/model-pick.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', sketch: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], comp: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
|
||||
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'IMPECCABLE’S PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as today’s ballad.', risk: 'Also the direction most runs in this category land on.', comp: '.impeccable/mocks/decision/model-pick.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', comp: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'challenger-microfiche', label: 'Microfiche Reader', verdict: 'declined', lineage: 'library microfiche stations', palette: ['#101418', '#9fb4c0'], materials: ['film grain', 'backlit glass'], case: 'Fuses poorly: listeners do not identify with archival retrieval.', kept: 'Total environmental commitment.', hero: 'https://impeccable.style/worlds/cards/archives-microfiche-reader-hero.webp' },
|
||||
],
|
||||
reroll: { registers: ['safer', 'bolder'] },
|
||||
buildPath: { value: 'comp', toggle: true },
|
||||
canon: true,
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', sketch: '.impeccable/mocks/decision/canon.webp' },
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', comp: '.impeccable/mocks/decision/canon.webp' },
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no sketch (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "MY PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. The sketch slot carries the card’s full-fidelity direction comp (the field keeps its wire name for compatibility). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. followup: true keeps the table open after a pick for a second round via --update (direction first, then the execution contract); send the next payload immediately, the page is waiting on it.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/comp accept URLs or local paths; comp slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the comp, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no comp (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "IMPECCABLE’S PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. A card may instead declare "wireframe" ({"cols":12,"rows":10,"regions":[{"label":"nav rail","x":0,"y":0,"w":3,"h":10,"accent":true}]}): the page draws it as a layout schematic in the media slot; surface-scope rounds use it on code-led builds, it never counts toward salience, and the card keeps its full read on the front. The comp slot carries the card’s full-fidelity direction comp (the legacy key "sketch" is accepted as an alias). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. buildPath rides the payload as { "value": "comp"|"code", "toggle": true }: the value is the recorded default (.impeccable/config.json buildPath, or .impeccable/config.local.json where one machine differs) and the toggle renders a footer switch whose flip binds that session only; the ANSWER then carries buildPath plus buildPathFlipped. On a code-led round each card still declares its comp path as a flip reserve: wireframes render, and a flip to comp makes --wait return once with BUILD PATH FLIPPED so you generate the comps into the declared slots while the round stays open; a flip back to code is free, and a comp that already landed stays as the critique reference. The toggle may only be offered when image generation exists: a harness with no image tool and no API key never sets toggle: true, so the choice never renders where comps cannot be made, and code-led simply rides as the untoggleable value. followup: true keeps the table open after a pick for a second round via --update; send the next payload immediately, the page is waiting on it.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -212,6 +230,13 @@ if (hasFlag('wait')) {
|
||||
let sawClose = false;
|
||||
while (Date.now() < deadline) {
|
||||
if (answered()) break;
|
||||
// A build-path flip is its own event, not an answer: the round stays
|
||||
// open, and the agent's job right now is comps, not code.
|
||||
if (fs.existsSync(flipFile(key))) {
|
||||
try { fs.rmSync(flipFile(key)); } catch { /* consumed elsewhere */ }
|
||||
console.log('BUILD PATH FLIPPED: comp (for this session only; never write it to settings). The table is still open and the page shows shimmer where the images will land: generate each open card’s comp into its declared path now, lead first, then collect the answer with --wait again. A card whose comp already exists needs nothing.');
|
||||
process.exit(0);
|
||||
}
|
||||
if (!alive()) {
|
||||
console.log('serve-question: the question server is gone with no answer. This is a server failure, not a user decision: restart it with --start and the same payload, reopen the URL for the user, and wait again. Never proceed without their choice while their browser session is open.');
|
||||
process.exit(2);
|
||||
@@ -307,6 +332,12 @@ else raw = fs.readFileSync(0, 'utf8');
|
||||
let payload;
|
||||
let options;
|
||||
let localImages = [];
|
||||
// Build path (comp-led vs code-led): the payload carries the recorded
|
||||
// default; the page's toggle updates the live value per session. The server
|
||||
// owns both so the final ANSWER states the path and whether it was flipped
|
||||
// even when the round never rendered a toggle.
|
||||
let buildPathDefault = null;
|
||||
let liveBuildPath = null;
|
||||
|
||||
function loadRound(json) {
|
||||
const parsed = JSON.parse(json);
|
||||
@@ -322,10 +353,10 @@ function loadRound(json) {
|
||||
localImages.push(abs);
|
||||
return `/img/${localImages.length - 1}`;
|
||||
};
|
||||
// Sketches stream in after the page is served, so their slots register
|
||||
// Comps stream in after the page is served, so their slots register
|
||||
// whether or not the file exists yet; /img answers 404 until it lands and
|
||||
// the page polls the slot. Remote sketch URLs pass through untouched.
|
||||
const sketchSrc = (value) => {
|
||||
// the page polls the slot. Remote comp URLs pass through untouched.
|
||||
const compSrc = (value) => {
|
||||
if (!value) return null;
|
||||
if (/^https?:\/\//.test(value)) return value;
|
||||
localImages.push(path.resolve(value));
|
||||
@@ -336,7 +367,7 @@ function loadRound(json) {
|
||||
...option,
|
||||
heroSrc: imageSrc(option.hero),
|
||||
boardSrc: imageSrc(option.board),
|
||||
sketchSrc: sketchSrc(option.sketch),
|
||||
compSrc: compSrc(option.comp ?? option.sketch),
|
||||
});
|
||||
options = parsed.options.map(decorate);
|
||||
// The verdict routes rendering: full cards first, then the canon, then the
|
||||
@@ -352,6 +383,10 @@ function loadRound(json) {
|
||||
options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
|
||||
}
|
||||
options = [...options, ...declined];
|
||||
buildPathDefault = (parsed.buildPath && (parsed.buildPath.value === 'comp' || parsed.buildPath.value === 'code'))
|
||||
? { value: parsed.buildPath.value, toggle: parsed.buildPath.toggle === true }
|
||||
: null;
|
||||
liveBuildPath = buildPathDefault?.value ?? null;
|
||||
}
|
||||
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
|
||||
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
|
||||
@@ -368,18 +403,25 @@ function page() {
|
||||
// no generation luck can distort.
|
||||
const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
|
||||
const demoted = (option) => option.verdict === 'declined';
|
||||
// The build path (comp-led vs code-led) is a workflow preference, not a
|
||||
// design decision: the payload carries the recorded default and whether
|
||||
// the page offers the toggle. On a code-led round a declared comp path is
|
||||
// a flip reserve, not a face: wireframes render, and the slot only starts
|
||||
// shimmering when the user flips to comp.
|
||||
const buildPath = buildPathDefault;
|
||||
const codeLed = buildPath?.value === 'code';
|
||||
// Salience parity: a card's imagery weight is capped by the assigned card's.
|
||||
// When the lead card has no media at all (no image generation this round,
|
||||
// and no catalog art of its own), full-bleed catalog art beside a text-only
|
||||
// assigned card would let rendering luck outvote the weighing: users click
|
||||
// the colorful thing. Declined cards are thumb-only regardless; the verdict
|
||||
// demoted them, and a full-bleed hero would promote them right back.
|
||||
const identityRound = !(options[0] && (options[0].sketchSrc || options[0].heroSrc || options[0].boardSrc));
|
||||
// A declined card never renders a full media face, sketch included: even a
|
||||
// declared sketch would buy back the salience the verdict took away.
|
||||
const faceSketch = (option) => demoted(option) ? null : option.sketchSrc;
|
||||
const thumbOnly = (option) => !faceSketch(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
|
||||
const hasMedia = (option) => Boolean(faceSketch(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
|
||||
const identityRound = !(options[0] && (options[0].compSrc || options[0].heroSrc || options[0].boardSrc));
|
||||
// A declined card never renders a full media face, comp included: even a
|
||||
// declared comp would buy back the salience the verdict took away.
|
||||
const faceComp = (option) => (demoted(option) || codeLed) ? null : option.compSrc;
|
||||
const thumbOnly = (option) => !faceComp(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
|
||||
const hasMedia = (option) => Boolean(faceComp(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
|
||||
// The back exists to keep long facts off a card whose front is an image;
|
||||
// a card with no art has no flip chip to reach it, so it gets no back and
|
||||
// the full read lives on the front instead.
|
||||
@@ -402,15 +444,16 @@ function page() {
|
||||
// generous hand cannot blow the card out of proportion.
|
||||
if (Array.isArray(option.raised) && option.raised.length) {
|
||||
const nameOf = (id) => options.find((o) => o.id === id)?.label || String(id ?? '');
|
||||
const raiseLines = option.raised.slice(0, 6).map((r) => `<p class="raise"><span class="fact-label">Raised by ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
|
||||
const raiseLines = option.raised.slice(0, 6).map((r) => `<p class="raise"><span class="fact-label">From ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
|
||||
const raisesHead = (count) => `<div class="raises-head"><span class="fact-label">Improved by Impeccable's worlds</span>${count > 1 ? `<span class="raises-count" data-raises-count>1/${count}</span>` : ''}</div>`;
|
||||
if (raiseLines.length > 1) {
|
||||
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter to see the next raise" aria-label="Raised by the hand; activate to see the next raise">
|
||||
<div class="raises-head"><span class="fact-label">Raised by the hand</span><span class="raises-count" data-raises-count>1/${raiseLines.length}</span></div>
|
||||
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter for the next improvement" aria-label="How Impeccable's worlds improved this direction; activate to see the next improvement">
|
||||
${raisesHead(raiseLines.length)}
|
||||
${raiseLines.join('')}
|
||||
<span class="sr-live" aria-live="polite"></span>
|
||||
</div>`);
|
||||
} else {
|
||||
rows.push(`<div class="raises">${raiseLines[0]}</div>`);
|
||||
rows.push(`<div class="raises">${raisesHead(1)}${raiseLines[0]}</div>`);
|
||||
}
|
||||
}
|
||||
// Demoted art stays reachable as a labeled thumb: the catalog world
|
||||
@@ -421,7 +464,7 @@ function page() {
|
||||
}
|
||||
// The front carries only what the choice needs: thesis, identity, and the
|
||||
// honest risk clamped to two lines. First viewport and the case read on
|
||||
// the card's back; once the sketch lands, the first viewport is a picture.
|
||||
// the card's back; once the comp lands, the first viewport is a picture.
|
||||
// With no art there is no back, so the full read fills the room the
|
||||
// image would have taken.
|
||||
if (hasMedia(option)) {
|
||||
@@ -444,24 +487,27 @@ function page() {
|
||||
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
|
||||
].filter(Boolean).join('\n ');
|
||||
const media = (option) => {
|
||||
const inspiration = option.heroSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(option.heroSrc)}" alt="">
|
||||
const inspirationSrc = option.heroSrc || option.boardSrc;
|
||||
const inspiration = inspirationSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(inspirationSrc)}" alt="">
|
||||
<figcaption>inspiration</figcaption>
|
||||
</figure>` : '';
|
||||
const details = hasBack(option) ? flipChip('Details') : '';
|
||||
// Thumb-only art renders inside the body via anatomy(), never as a face,
|
||||
// and a declined card's sketch slot is ignored outright.
|
||||
// and a declined card's comp slot is ignored outright.
|
||||
if (thumbOnly(option)) return '';
|
||||
if (faceSketch(option)) {
|
||||
return `<div class="media sketching" data-sketch="${esc(option.sketchSrc)}">
|
||||
<div class="shimmer"><span class="sketch-note">rendering…</span></div>
|
||||
<img class="sketch" alt="" hidden>
|
||||
if (faceComp(option)) {
|
||||
const textOnlyFacts = backFacts(option);
|
||||
return `<div class="media comp-pending" data-comp="${esc(option.compSrc)}">
|
||||
<div class="shimmer"><span class="comp-note">rendering…</span></div>
|
||||
<img class="comp" alt="" hidden>
|
||||
${inspiration}
|
||||
<template class="text-only-facts">${textOnlyFacts}</template>
|
||||
<div class="chips">${expandChip}${details}</div>
|
||||
</div>`;
|
||||
}
|
||||
if (option.heroSrc || option.boardSrc) {
|
||||
// Without a sketch the catalog art is the card's face; it stays a
|
||||
// Without a comp the catalog art is the card's face; it stays a
|
||||
// labeled reference so it never reads as the promise of the build.
|
||||
return `<div class="media" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||
@@ -471,13 +517,36 @@ function page() {
|
||||
}
|
||||
return '';
|
||||
};
|
||||
// Wireframe media: a code-led card's layout schematic, authored as grid
|
||||
// regions in the payload and drawn by the page; boxes and labels, no art.
|
||||
// It fills the media slot only when the card has no imagery, and it never
|
||||
// counts toward salience or earns a card back: the full read stays on the
|
||||
// front, exactly like a text-only card.
|
||||
const wire = (option) => {
|
||||
const frame = option.wireframe;
|
||||
if (!frame || !Array.isArray(frame.regions) || !frame.regions.length || media(option) || demoted(option)) return '';
|
||||
const cols = Number(frame.cols) > 0 ? Number(frame.cols) : 12;
|
||||
const rows = Number(frame.rows) > 0 ? Number(frame.rows) : 10;
|
||||
const pct = (n, total) => `${Math.max(0, Math.min(100, (n / total) * 100)).toFixed(2)}%`;
|
||||
const cells = frame.regions.slice(0, 12).map((region) => {
|
||||
const x = Number(region.x) || 0;
|
||||
const y = Number(region.y) || 0;
|
||||
const w = Math.max(Number(region.w) || 1, 0.5);
|
||||
const h = Math.max(Number(region.h) || 1, 0.5);
|
||||
return `<div class="wire-region${region.accent ? ' accent' : ''}" style="left:${pct(x, cols)};top:${pct(y, rows)};width:${pct(w, cols)};height:${pct(h, rows)}"><span>${esc(region.label || '')}</span></div>`;
|
||||
}).join('');
|
||||
return `<div class="media wire" role="img" aria-label="Layout schematic">
|
||||
<div class="wire-field">${cells}</div>
|
||||
<p class="media-label">layout</p>
|
||||
</div>`;
|
||||
};
|
||||
const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this';
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}"${codeLed && option.compSrc && !demoted(option) ? ` data-comp-slot="${esc(option.compSrc)}"` : ''}>
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${(media(option) || wire(option)) ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : demoted(option) ? '<span class="kicker declined-k">Declined</span>' : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
|
||||
${media(option)}
|
||||
${media(option) || wire(option)}
|
||||
<div class="body">
|
||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||
<h2>${esc(option.label)}</h2>
|
||||
@@ -526,9 +595,12 @@ function page() {
|
||||
--ks-font-display: "Alumni Sans", "Albert Sans", Arial, sans-serif;
|
||||
--ks-font: "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, system-ui, sans-serif;
|
||||
--ks-mono: "SFMono-Regular", "Roboto Mono", "JetBrains Mono", Consolas, monospace;
|
||||
/* One inset shared by the content column, the deck's snap padding, and
|
||||
the sticky footer, so all three align on the same 90rem column. */
|
||||
--page-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2));
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 2rem; min-height: 100dvh; display: flex; flex-direction: column; overflow-x: clip; }
|
||||
body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 0; min-height: 100dvh; display: flex; flex-direction: column; overflow-x: clip; }
|
||||
#ambient { position: fixed; inset: -40px; z-index: 0; background-size: cover; background-position: center; filter: blur(34px) saturate(1.05); opacity: 0; transition: opacity .55s ease, background-image .2s; pointer-events: none; }
|
||||
#scrim { position: fixed; inset: 0; z-index: 0; background: linear-gradient(180deg, oklch(7% 0.006 95 / 0.62), oklch(7% 0.006 95 / 0.78)); pointer-events: none; }
|
||||
header, main, footer { position: relative; z-index: 1; }
|
||||
@@ -540,7 +612,7 @@ function page() {
|
||||
.brand { display: flex; align-items: center; gap: .55rem; color: var(--ks-kinpaku); }
|
||||
.brand svg { width: 22px; height: 22px; }
|
||||
.wordmark { font-family: var(--ks-font-display); font-weight: 400; font-size: 1.125rem; letter-spacing: 0.15em; text-transform: uppercase; line-height: 1; color: var(--ks-kinpaku); }
|
||||
.headline { display: flex; align-items: center; gap: .9rem; }
|
||||
.headline { display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; }
|
||||
.headline-die { flex: none; width: 34px; height: 34px; color: var(--ks-kinpaku); }
|
||||
h1 { font-family: var(--ks-font-display); font-weight: 100; font-size: clamp(2.6rem, 5vw, 4.2rem); letter-spacing: -0.01em; line-height: 1.02; color: var(--ks-champagne); }
|
||||
.question { color: var(--ks-text-muted); margin-top: .7rem; max-width: 52rem; }
|
||||
@@ -552,12 +624,24 @@ function page() {
|
||||
.deck-shell { position: relative; width: 100vw; margin-left: calc(50% - 50vw); }
|
||||
/* One row in a wide viewport, one column in a tall one; the deck scrolls on
|
||||
its axis with snap points and the arrows page it card by card. */
|
||||
.grid { --deck-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2)); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; }
|
||||
.grid { --deck-inset: var(--page-inset); display: flex; gap: 1.6rem; width: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; padding: 6px var(--deck-inset); scroll-padding-inline: var(--deck-inset); align-items: stretch; }
|
||||
.grid::-webkit-scrollbar { display: none; }
|
||||
/* Wide enough that the sketch carries the card: at 27vw the imagery read
|
||||
/* Wide enough that the comp carries the card: at 27vw the imagery read
|
||||
as a thumbnail above a column of copy, and the copy won the attention
|
||||
contest the sketch is supposed to win. */
|
||||
contest the comp is supposed to win. */
|
||||
.grid > .card { flex: 0 0 clamp(24rem, 34vw, 34rem); scroll-snap-align: center; }
|
||||
/* Short landscape viewports (13-inch laptops): header, a 34vw card, and the
|
||||
footer do not fit 800px of height, so the headline compacts and the deck
|
||||
narrows. Height is the axis that gives; the sticky footer keeps the
|
||||
round's verbs on screen while a too-tall card scrolls. */
|
||||
@media (min-aspect-ratio: 1/1) and (max-height: 900px) {
|
||||
body { padding-top: 1.1rem; }
|
||||
h1 { font-size: clamp(2rem, 3.4vw, 2.9rem); }
|
||||
.question { margin-top: .45rem; }
|
||||
.stage { gap: 1rem; }
|
||||
.grid > .card { flex-basis: clamp(20rem, 27vw, 27rem); }
|
||||
.grid > .card.declined { flex-basis: clamp(13rem, 18vw, 18rem); }
|
||||
}
|
||||
.nav { position: absolute; z-index: 6; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: oklch(7% 0.006 95 / 0.78); border: 1px solid var(--ks-rule); color: var(--ks-kinpaku); cursor: pointer; backdrop-filter: blur(6px); transition: border-color .2s, color .2s, opacity .2s; }
|
||||
.nav:hover { border-color: var(--ks-kinpaku-deep); color: var(--ks-kinpaku-pale); }
|
||||
.nav[disabled] { opacity: .25; cursor: default; }
|
||||
@@ -588,6 +672,10 @@ function page() {
|
||||
shrink a declined card to content WIDTH, not height, so it stretches
|
||||
like every other card and its height is already its own. */
|
||||
.grid > .card.declined { align-self: stretch; }
|
||||
/* The sticky bar is a wide-viewport fix. Here it would sit over the
|
||||
deck's More pager and cost a third of a phone screen, and the deck
|
||||
already scrolls internally, so the footer stays in the page flow. */
|
||||
footer { position: static; width: auto; margin: 1rem 0 0; padding: .7rem 0 1.2rem; background: transparent; border-top: 0; backdrop-filter: none; }
|
||||
}
|
||||
.card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
|
||||
.card:hover { transform: rotate(0deg) translateY(-4px); }
|
||||
@@ -611,7 +699,7 @@ function page() {
|
||||
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; }
|
||||
/* 16/10 matches the landscape sketch frame; portrait art overrides the
|
||||
/* 16/10 matches the landscape comp frame; portrait art overrides the
|
||||
slot with its own exact ratio at load (see the load listener), and the
|
||||
deck narrows so portrait cards line up side by side. */
|
||||
.media { position: relative; width: 100%; aspect-ratio: 16/10; flex: none; }
|
||||
@@ -647,14 +735,14 @@ function page() {
|
||||
.body.back-body { overflow-y: auto; flex: 1; scrollbar-width: thin; }
|
||||
/* Inspiration rides picture-in-picture: the catalog world explains where the
|
||||
direction comes from without promising what the build will look like. */
|
||||
/* Hovering the inspiration takes over the whole media region; the sketch is
|
||||
/* Hovering the inspiration takes over the whole media region; the comp is
|
||||
the promise, the inspiration is a glance, so the glance must cost nothing. */
|
||||
.pip { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; width: 84px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; background: var(--ks-lacquer); cursor: zoom-in; transition: left .35s cubic-bezier(.16,1,.3,1), bottom .35s cubic-bezier(.16,1,.3,1), width .35s cubic-bezier(.16,1,.3,1), height .35s cubic-bezier(.16,1,.3,1), border-radius .35s ease; box-shadow: 0 6px 18px oklch(0% 0 0 / 0.45); }
|
||||
.pip img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 3px 0 4px; background: oklch(7% 0.006 95 / 0.72); backdrop-filter: blur(3px); }
|
||||
.pip:hover { left: 0; bottom: 0; width: 100%; height: 100%; border-radius: 0; z-index: 3; }
|
||||
.sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||
/* Catalog art standing in for a sketchless card is a reference, and says so
|
||||
.comp-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||
/* Catalog art standing in for a comp-less card is a reference, and says so
|
||||
on its face; the same pill later carries "artwork unavailable". */
|
||||
.media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); padding: 3px 8px 4px; background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 4px; backdrop-filter: blur(3px); }
|
||||
/* Art that never arrives collapses to the card's own palette (painted
|
||||
@@ -666,15 +754,15 @@ function page() {
|
||||
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
|
||||
.media.unavailable .chips { z-index: 2; }
|
||||
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
|
||||
the real sketch whenever it lands. */
|
||||
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
|
||||
the real comp whenever it lands. */
|
||||
.media.stand-in img.comp { filter: brightness(.72) saturate(.85); }
|
||||
.media.stand-in .pip { display: none; }
|
||||
.stand-in-label { position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; margin: 0; font-family: var(--ks-mono); font-size: .56rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 4px 0 5px; background: oklch(7% 0.006 95 / 0.78); backdrop-filter: blur(3px); }
|
||||
.media.sketching { position: relative; }
|
||||
.media.sketching .shimmer { position: absolute; inset: 0; }
|
||||
.media img.sketch { position: relative; z-index: 1; }
|
||||
.media.comp-pending { position: relative; }
|
||||
.media.comp-pending .shimmer { position: absolute; inset: 0; }
|
||||
.media img.comp { position: relative; z-index: 1; }
|
||||
/* The generic .media img display:block would defeat [hidden] and float an
|
||||
empty block over the shimmer; an unloaded sketch must truly not render. */
|
||||
empty block over the shimmer; an unloaded comp must truly not render. */
|
||||
.media img[hidden] { display: none; }
|
||||
/* Declined challengers: the weighing demoted them, so the card is narrower
|
||||
and quieter, its catalog art rides as a labeled thumb in the body, and
|
||||
@@ -690,22 +778,33 @@ function page() {
|
||||
.kicker.declined-k { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); }
|
||||
.card.declined button.choose { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font-size: .85rem; padding: 8px 22px; }
|
||||
.card.declined button.choose:hover { background: var(--ks-graphite-2); border-color: var(--ks-text-muted); }
|
||||
/* Wireframe media: the code-led schematic. Quiet boxes in the card's own
|
||||
chrome; uniform salience across cards by construction, so it needs no
|
||||
parity rules. */
|
||||
.media.wire { background: var(--ks-lacquer); border-bottom: 1px solid var(--ks-rule); }
|
||||
.wire-field { position: absolute; inset: 12px 12px 26px; }
|
||||
.wire-region { position: absolute; border: 1px solid oklch(78% 0 0 / 0.26); border-radius: 3px; background: oklch(78% 0 0 / 0.05); display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
||||
.wire-region span { font-family: var(--ks-mono); font-size: .55rem; letter-spacing: .1em; text-transform: uppercase; color: var(--ks-text-faint); text-align: center; padding: 2px 4px; }
|
||||
.wire-region.accent { border-color: oklch(84% 0.19 80.46 / 0.5); background: oklch(84% 0.19 80.46 / 0.06); }
|
||||
.wire-region.accent span { color: var(--ks-kinpaku-rich); }
|
||||
/* Thumb-scale inspiration: present, labeled, zoomable, and incapable of
|
||||
outshouting a text-only assigned card. */
|
||||
.inspo { position: relative; flex: none; margin: 2px 0; width: 104px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; cursor: zoom-in; background: var(--ks-lacquer); }
|
||||
.inspo img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.inspo figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .48rem; letter-spacing: .16em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 2px 0 3px; background: oklch(7% 0.006 95 / 0.72); }
|
||||
/* Raises: the donations the assigned direction took from the hand it beat,
|
||||
each named for its donor. Patina, not kinpaku: a raise is provenance. */
|
||||
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; }
|
||||
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; border-left: 2px solid var(--ks-patina); padding-left: 8px; }
|
||||
/* Raises: the improvements the dealt worlds donated to the assigned
|
||||
direction, each named for its donor world. Patina, not kinpaku:
|
||||
provenance, not a call to action. A quiet contained panel, never an
|
||||
accent side-tab. */
|
||||
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; padding: 7px 10px 8px; background: oklch(70% 0.12 188 / 0.06); border: 1px solid oklch(70% 0.12 188 / 0.22); border-radius: 8px; }
|
||||
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; }
|
||||
.raise .fact-label { color: var(--ks-patina); }
|
||||
/* Several raises cycle instead of stacking: one visible at a time, a
|
||||
/* Several kept ideas cycle instead of stacking: one visible at a time, a
|
||||
counter for the rest, the whole block advances on click. */
|
||||
.raises-cycle { cursor: pointer; border-radius: 6px; }
|
||||
.raises-cycle .raise { display: none; border-left: none; padding-left: 0; }
|
||||
.raises-cycle { cursor: pointer; transition: border-color .2s ease; }
|
||||
.raises-cycle:hover { border-color: oklch(70% 0.12 188 / 0.45); }
|
||||
.raises-cycle .raise { display: none; }
|
||||
.raises-cycle .raise.active { display: block; }
|
||||
.raises-cycle { border-left: 2px solid var(--ks-patina); padding-left: 8px; }
|
||||
.raises-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.raises-head .fact-label { color: var(--ks-patina); }
|
||||
.raises-count { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .14em; color: var(--ks-text-faint); }
|
||||
@@ -721,9 +820,39 @@ function page() {
|
||||
.card.canon button.choose:hover { border-color: var(--ks-text-muted); background: var(--ks-graphite-2); }
|
||||
button.choose { margin-top: auto; align-self: start; background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font-family: var(--ks-font); font-size: 1rem; font-weight: 500; line-height: 1.35; padding: 10px 38px; border-radius: 6px; cursor: pointer; transition: background .15s; }
|
||||
button.choose:hover { background: var(--ks-kinpaku-pale); }
|
||||
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
||||
/* The round's verbs stay reachable on short viewports: the footer is a
|
||||
full-bleed bar stuck to the viewport bottom and the deck scrolls under
|
||||
it. Same inset as the content column, so the controls stay aligned. */
|
||||
footer { position: sticky; bottom: 0; z-index: 10; width: 100vw; margin: 1.2rem calc(50% - 50vw) 0; padding: .7rem var(--page-inset) calc(.7rem + env(safe-area-inset-bottom, 0px)); display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: oklch(7% 0.006 95 / 0.82); backdrop-filter: blur(10px); border-top: 1px solid var(--ks-rule); }
|
||||
#steer { flex: 1; min-width: 16rem; background: var(--ks-lacquer-raised); color: var(--ks-text); border: 1px solid var(--ks-rule); border-radius: 7px; padding: .6rem .85rem; font: inherit; }
|
||||
#steer:focus { outline: none; border-color: var(--ks-patina); }
|
||||
/* Build-path toggle: a workflow preference surfaced as a quiet segmented
|
||||
control on the headline row, right-aligned opposite the title, its trade stated in
|
||||
one line that changes with the selection. The default comes from the
|
||||
payload (settings); flipping binds this session only, and the agent
|
||||
learns about a code-to-comp flip live. Rendered only when the payload
|
||||
offers it, which the agent does only when image generation exists. */
|
||||
#build-path { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; flex: none; margin-left: auto; }
|
||||
.bp-switch { display: inline-flex; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; }
|
||||
.bp-note { text-align: right; }
|
||||
.bp-opt { font-family: var(--ks-mono); font-size: .62rem; letter-spacing: .12em; text-transform: uppercase; padding: 7px 12px; background: transparent; border: 0; color: var(--ks-text-faint); cursor: pointer; transition: color .2s ease, background-color .2s ease; }
|
||||
.bp-opt + .bp-opt { border-left: 1px solid var(--ks-rule); }
|
||||
.bp-opt.active { color: var(--ks-dark-ink); background: var(--ks-kinpaku-rich); }
|
||||
.bp-opt:not(.active):hover { color: var(--ks-text); }
|
||||
.bp-note { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .04em; color: var(--ks-text-faint); max-width: 21rem; line-height: 1.5; }
|
||||
/* Flipping to comp starts billed, minutes-long generation, so it asks
|
||||
first; flipping back is free and never does. */
|
||||
#bp-confirm { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; background: oklch(4% 0.004 95 / 0.72); opacity: 0; transition: opacity .2s ease; }
|
||||
#bp-confirm[hidden] { display: none; }
|
||||
#bp-confirm.open { opacity: 1; }
|
||||
.bp-confirm-panel { max-width: 26rem; margin: 1rem; background: var(--ks-lacquer-raised); border: 1px solid var(--ks-rule); border-radius: 10px; padding: 1.4rem 1.5rem 1.3rem; box-shadow: 0 30px 80px oklch(0% 0 0 / 0.55); }
|
||||
.bp-confirm-panel h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; color: var(--ks-champagne); margin-bottom: .55rem; }
|
||||
.bp-confirm-panel p { font-size: .875rem; line-height: 1.55; color: var(--ks-text-muted); }
|
||||
.bp-confirm-actions { display: flex; gap: .6rem; margin-top: 1.1rem; }
|
||||
.bp-confirm-go { background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font: inherit; font-weight: 500; padding: 9px 22px; border-radius: 6px; cursor: pointer; }
|
||||
.bp-confirm-go:hover { background: var(--ks-kinpaku-pale); }
|
||||
.bp-confirm-stay { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font: inherit; padding: 9px 18px; border-radius: 6px; cursor: pointer; }
|
||||
.bp-confirm-stay:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
|
||||
.reroll-btn { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
.reroll-btn:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
.reroll-btn svg { width: 15px; height: 15px; }
|
||||
@@ -752,6 +881,16 @@ function page() {
|
||||
<div id="ambient" aria-hidden="true"></div>
|
||||
<div id="scrim" aria-hidden="true"></div>
|
||||
<div id="lightbox" hidden><img alt=""></div>
|
||||
${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria-labelledby="bp-confirm-title" hidden>
|
||||
<div class="bp-confirm-panel">
|
||||
<h2 id="bp-confirm-title">Flip to comp-first?</h2>
|
||||
<p>The agent starts rendering a comp for every open card right away, about a minute or two per card on your image provider, and the images land on the cards as they finish. This flip binds this session only.</p>
|
||||
<div class="bp-confirm-actions">
|
||||
<button type="button" class="bp-confirm-go" data-confirm>Render comps</button>
|
||||
<button type="button" class="bp-confirm-stay" data-cancel>Keep code-first</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<header>
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>
|
||||
@@ -763,6 +902,13 @@ function page() {
|
||||
<div class="headline">
|
||||
<svg class="headline-die" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>
|
||||
<h1>${esc(payload.title || 'Choose a direction')}</h1>
|
||||
${buildPath?.toggle ? `<div id="build-path" data-default="${buildPath.value}">
|
||||
<div class="bp-switch" role="radiogroup" aria-label="Build path">
|
||||
<button type="button" class="bp-opt" data-bp="comp" role="radio" aria-checked="false">Comp first</button>
|
||||
<button type="button" class="bp-opt" data-bp="code" role="radio" aria-checked="false">Code first</button>
|
||||
</div>
|
||||
<p class="bp-note" data-bp-note></p>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
${payload.question ? `<p class="question">${esc(payload.question)}</p>` : ''}
|
||||
<div class="deck-shell">
|
||||
@@ -822,7 +968,7 @@ function page() {
|
||||
if (count) count.textContent = (at + 1) + '/' + raises.length;
|
||||
// Screen readers hear the raise they just advanced to; the initial
|
||||
// render stays quiet so page load does not narrate every card.
|
||||
if (announce && live) live.textContent = 'Raise ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');
|
||||
if (announce && live) live.textContent = 'Improvement ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');
|
||||
};
|
||||
show(false);
|
||||
const advance = (e) => { e.stopPropagation(); at = (at + 1) % raises.length; show(true); };
|
||||
@@ -863,46 +1009,147 @@ function page() {
|
||||
}));
|
||||
}
|
||||
|
||||
// Sketches stream in after the deal: poll each slot until the file lands,
|
||||
// Comps stream in after the deal: poll each slot until the file lands,
|
||||
// then swap the shimmer for the image. Generation is genuinely slow and a
|
||||
// sequential batch puts the last card many minutes out, so patience is the
|
||||
// default: a slot only shows its inspiration as a stand-in when it has
|
||||
// waited four minutes AND nothing has landed anywhere for four minutes, the
|
||||
// stand-in is labeled as such, and polling continues so the real sketch
|
||||
// stand-in is labeled as such, and polling continues so the real comp
|
||||
// still swaps in whenever it arrives. Progress anywhere resets patience.
|
||||
const landTracker = { last: Date.now() };
|
||||
document.querySelectorAll('.media.sketching').forEach(m => {
|
||||
const url = m.dataset.sketch;
|
||||
const img = m.querySelector('img.sketch');
|
||||
const note = m.querySelector('.sketch-note');
|
||||
const pollComp = (m) => {
|
||||
const url = m.dataset.comp;
|
||||
const img = m.querySelector('img.comp');
|
||||
const note = m.querySelector('.comp-note');
|
||||
const started = Date.now();
|
||||
// A live elapsed count is the difference between "working" and "frozen".
|
||||
const tick = setInterval(() => { if (note) note.textContent = 'rendering · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
|
||||
const settle = () => { clearInterval(tick); m.classList.remove('sketching', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
|
||||
const standIn = () => {
|
||||
const settle = () => { clearInterval(tick); m.classList.remove('comp-pending', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
|
||||
const fallback = () => {
|
||||
const pip = m.querySelector('.pip img');
|
||||
if (!pip || m.classList.contains('stand-in')) return;
|
||||
img.src = pip.getAttribute('src'); img.hidden = false;
|
||||
m.classList.add('stand-in');
|
||||
m.querySelector('.shimmer')?.remove();
|
||||
clearInterval(tick);
|
||||
const label = document.createElement('p');
|
||||
label.className = 'stand-in-label';
|
||||
label.textContent = 'inspiration · comp pending';
|
||||
m.appendChild(label);
|
||||
if (pip) {
|
||||
if (m.classList.contains('stand-in')) return false;
|
||||
img.src = pip.getAttribute('src'); img.hidden = false;
|
||||
m.classList.add('stand-in');
|
||||
m.querySelector('.shimmer')?.remove();
|
||||
clearInterval(tick);
|
||||
const label = document.createElement('p');
|
||||
label.className = 'stand-in-label';
|
||||
label.textContent = 'inspiration · comp pending';
|
||||
m.appendChild(label);
|
||||
return false;
|
||||
}
|
||||
|
||||
// No comp and no inspiration is the text-only card the payload would
|
||||
// have rendered without a comp declaration. Bring the complete read
|
||||
// forward before removing the now-unreachable back face.
|
||||
const card = m.closest('.card');
|
||||
const front = card?.querySelector('.face.front');
|
||||
const body = front?.querySelector('.body');
|
||||
const back = card?.querySelector('.face.back');
|
||||
const textOnlyFacts = m.querySelector('template.text-only-facts');
|
||||
const choose = body?.querySelector(':scope > button.choose');
|
||||
if (body && textOnlyFacts && choose) {
|
||||
const plainDetail = body.querySelector(':scope > .detail:not(.more)');
|
||||
[...body.children].filter((el) => el.classList.contains('fact') || el.matches('.detail.more')).forEach((el) => el.remove());
|
||||
choose.before(textOnlyFacts.content.cloneNode(true));
|
||||
if (plainDetail) choose.before(plainDetail);
|
||||
}
|
||||
card?.classList.remove('flipped');
|
||||
front?.classList.add('text-only');
|
||||
back?.remove();
|
||||
settle();
|
||||
m.remove();
|
||||
return true;
|
||||
};
|
||||
const tryLoad = () => {
|
||||
// A slot the user flipped back out of leaves the DOM; let its loop die.
|
||||
if (!m.isConnected) { clearInterval(tick); return; }
|
||||
const probe = new Image();
|
||||
probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); };
|
||||
probe.onerror = () => {
|
||||
const quiet = Date.now() - landTracker.last > 240000;
|
||||
if (Date.now() - started > 240000 && quiet) standIn();
|
||||
if (Date.now() - started > 240000 && quiet && fallback()) return;
|
||||
setTimeout(tryLoad, m.classList.contains('stand-in') ? 5000 : 2500);
|
||||
};
|
||||
probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||
};
|
||||
tryLoad();
|
||||
});
|
||||
};
|
||||
document.querySelectorAll('.media.comp-pending').forEach(pollComp);
|
||||
|
||||
// Build-path toggle: the default is the round's recorded preference and
|
||||
// flipping binds this session only. Flipping code to comp swaps every
|
||||
// reserve slot (data-comp-slot) to its shimmer and tells the server, so
|
||||
// the waiting agent starts generating; flipping back is free: pending
|
||||
// slots return to their wireframes, a comp that already landed stays.
|
||||
const bp = document.getElementById('build-path');
|
||||
if (bp) {
|
||||
const notes = {
|
||||
comp: 'An image sets the bar first and the build must match it. Bolder composition; comps render before code.',
|
||||
code: 'Code builds directly; the ambition is written into the contract and audited at the finish. Leaner, faster.',
|
||||
};
|
||||
const noteEl = bp.querySelector('[data-bp-note]');
|
||||
let current = bp.dataset.default;
|
||||
const set = (value) => {
|
||||
current = value;
|
||||
bp.querySelectorAll('.bp-opt').forEach(b => {
|
||||
const on = b.dataset.bp === value;
|
||||
b.classList.toggle('active', on);
|
||||
b.setAttribute('aria-checked', String(on));
|
||||
});
|
||||
if (noteEl) noteEl.textContent = notes[value];
|
||||
};
|
||||
set(current);
|
||||
const enterComp = () => {
|
||||
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
|
||||
const front = card.querySelector('.face.front');
|
||||
if (!front || front.querySelector('.media.comp-pending') || front.querySelector('.media img.comp:not([hidden])')) return;
|
||||
const m = document.createElement('div');
|
||||
m.className = 'media comp-pending';
|
||||
m.dataset.comp = card.dataset.compSlot;
|
||||
m.innerHTML = '<div class="shimmer"><span class="comp-note">rendering…</span></div><img class="comp" alt="" hidden>';
|
||||
const wireEl = front.querySelector('.media.wire');
|
||||
if (wireEl) { wireEl.hidden = true; front.insertBefore(m, wireEl); }
|
||||
else { front.classList.remove('text-only'); front.insertBefore(m, front.querySelector('.body')); }
|
||||
pollComp(m);
|
||||
});
|
||||
};
|
||||
const exitComp = () => {
|
||||
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
|
||||
const front = card.querySelector('.face.front');
|
||||
const pending = front?.querySelector('.media.comp-pending');
|
||||
if (!pending) return; // landed comps stay; they exist either way
|
||||
pending.remove();
|
||||
const wireEl = front.querySelector('.media.wire');
|
||||
if (wireEl) wireEl.hidden = false;
|
||||
else if (!front.querySelector('.media')) front.classList.add('text-only');
|
||||
});
|
||||
};
|
||||
const apply = (value) => {
|
||||
set(value);
|
||||
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
|
||||
if (value === 'comp') enterComp(); else exitComp();
|
||||
};
|
||||
// Flipping to comp starts real generation, so it confirms first; the
|
||||
// flip back is free and applies immediately.
|
||||
const confirm = document.getElementById('bp-confirm');
|
||||
const closeConfirm = () => { confirm.classList.remove('open'); confirm.hidden = true; };
|
||||
confirm.querySelector('[data-confirm]').addEventListener('click', () => { closeConfirm(); apply('comp'); });
|
||||
confirm.querySelector('[data-cancel]').addEventListener('click', closeConfirm);
|
||||
confirm.addEventListener('click', (e) => { if (e.target === confirm) closeConfirm(); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !confirm.hidden) closeConfirm(); });
|
||||
bp.querySelectorAll('.bp-opt').forEach(b => b.addEventListener('click', () => {
|
||||
const value = b.dataset.bp;
|
||||
if (value === current) return;
|
||||
if (value === 'comp') {
|
||||
confirm.hidden = false;
|
||||
requestAnimationFrame(() => confirm.classList.add('open'));
|
||||
return;
|
||||
}
|
||||
apply(value);
|
||||
}));
|
||||
}
|
||||
|
||||
// A declared image that never loads (missing catalog asset, offline shell)
|
||||
// must not sit as a dark void: the slot collapses to the card's own
|
||||
@@ -910,7 +1157,7 @@ function page() {
|
||||
// slots are excluded; their polling owns the wait.
|
||||
const artFailed = (img) => {
|
||||
const m = img.closest('.media');
|
||||
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
|
||||
if (!m || m.classList.contains('comp-pending') || m.classList.contains('unavailable')) return;
|
||||
m.classList.add('unavailable');
|
||||
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
|
||||
if (colors.length) m.style.background = 'linear-gradient(135deg, ' + colors.map((c, i) => c + ' ' + Math.round(i * 100 / colors.length) + '% ' + Math.round((i + 1) * 100 / colors.length) + '%').join(', ') + ')';
|
||||
@@ -923,7 +1170,7 @@ function page() {
|
||||
label.textContent = 'artwork unavailable';
|
||||
m.appendChild(label);
|
||||
};
|
||||
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
|
||||
document.querySelectorAll('.media:not(.comp-pending) > img').forEach(img => {
|
||||
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
||||
else img.addEventListener('error', () => artFailed(img), { once: true });
|
||||
});
|
||||
@@ -1109,6 +1356,26 @@ const server = http.createServer((req, res) => {
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/build-path') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end('{"ok":true}');
|
||||
let value = null;
|
||||
try { value = JSON.parse(body).value; } catch { /* ignore */ }
|
||||
if (value !== 'comp' && value !== 'code') return;
|
||||
const wasComp = liveBuildPath === 'comp';
|
||||
liveBuildPath = value;
|
||||
// Only a flip TO comp needs the agent mid-round: comps must start
|
||||
// rendering into the declared slots. The reverse is free.
|
||||
if (detachedKey && value === 'comp' && !wasComp) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/answer') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
@@ -1129,7 +1396,8 @@ const server = http.createServer((req, res) => {
|
||||
...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}),
|
||||
...(followupOpen ? { followup: true } : {}),
|
||||
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||
...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
|
||||
...((chosen?.comp ?? chosen?.sketch) ? { comp: chosen.comp ?? chosen.sketch } : {}),
|
||||
...(liveBuildPath && !isReroll ? { buildPath: liveBuildPath, buildPathFlipped: liveBuildPath !== (buildPathDefault?.value ?? null) } : {}),
|
||||
});
|
||||
if (detachedKey) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
|
||||
@@ -6,7 +6,7 @@ An open direction round owns the word first: "bolder" said while a direction dec
|
||||
|
||||
## Scope is sovereign
|
||||
|
||||
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, stop and ask the user directly to clarify what you cannot infer. before expanding it, naming the exact addition and the job it would do.
|
||||
"Everything else stays" is a literal instruction. Touch only the named target. Do not restyle its neighbors, do not migrate the page to a new idea, do not add colors, fonts, radii, shadows, or system primitives the surface does not already own. If the existing system genuinely cannot express the direction, do not expand it on your own. Ask the user directly to clarify what you cannot infer. Name the exact addition and the job it would do.
|
||||
|
||||
## Why it reads flat
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ Resolve one stable target, run two independent assessments, synthesize a design
|
||||
- Viewable targets require browser inspection when available.
|
||||
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
|
||||
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
|
||||
- The question is the LAST thing in the response. Write the entire report out first, then ask; nothing follows the question. Prose emitted after a structured question is withheld until the user answers it, so a report written after the question reads as if the critique never ran.
|
||||
- A run that ends with neither the targeted questions nor a literal `Questions skipped: <reason>` line is an incomplete run. The report is not the finish; the close is.
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -172,6 +174,14 @@ Provocative questions that might unlock better solutions:
|
||||
- Prioritize ruthlessly. If everything is important, nothing is.
|
||||
- Don't soften criticism. Developers need honest feedback to ship great design.
|
||||
|
||||
### Deliver the Report
|
||||
|
||||
Write the full report into the chat response now, before any persistence work. This is the deliverable; everything below it is bookkeeping.
|
||||
|
||||
Do this first because the alternative is the most common way this command fails: the report gets composed once, straight into the persistence heredoc, and the run ends with a perfect archive nobody has read. Composing it into a file is not delivering it. If the report exists only in `.impeccable/critique/`, the run produced nothing.
|
||||
|
||||
Persistence is not the end of the run. After it, the response continues with the trend line and the close.
|
||||
|
||||
### Persist the Snapshot
|
||||
|
||||
Once the report above is finalized, write it to `.impeccable/critique/` so the user can refer back, and so `/impeccable polish` can pick up the priority issues without a copy-paste.
|
||||
@@ -180,6 +190,8 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
|
||||
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, design-specificity verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
|
||||
|
||||
This is a copy of the report you already delivered above, for later commands to read. It is not delivery. If you find yourself composing the report for the first time inside this heredoc, you have skipped Deliver the Report; go back and send it.
|
||||
|
||||
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
|
||||
```bash
|
||||
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
|
||||
@@ -204,11 +216,15 @@ Skip this step if the Setup slug was null (vague or root-level target).
|
||||
|
||||
If this is the first run for the slug, the trend is just one score; say so: "First run for this target, no trend yet."
|
||||
|
||||
6. **Close the run.** Go to Ask the User below and emit the questions, or the `Questions skipped: <reason>` line when the count allows it. The run is not complete until you do. Persistence is bookkeeping and cleanup is not an ending; stopping here leaves the user with a report and no way forward, and leaves `/impeccable polish` with no priorities to inherit.
|
||||
|
||||
This is fire-and-forget. Do not show the user the helper's JSON output; only the human-readable trend line and the written path. Failures here should not block the rest of the flow; print the error and move on.
|
||||
|
||||
### Ask the User
|
||||
|
||||
**After presenting findings**, use targeted questions based on what was actually found. ask the user directly to clarify what you cannot infer. These answers will shape the action plan.
|
||||
**After presenting findings**, use targeted questions based on what was actually found. Ask the user directly to clarify what you cannot infer. These answers will shape the action plan.
|
||||
|
||||
Ask in the same message that carries the report, with the report written out first and the question last. Do not split the two across turns: a turn that ends on the report is a turn that ends, and the questions never arrive. Order within the message is what matters, because prose emitted after a structured question is withheld until the user answers.
|
||||
|
||||
Ask questions along these lines (adapt to the specific findings; do NOT ask generic questions):
|
||||
|
||||
@@ -224,7 +240,9 @@ Ask questions along these lines (adapt to the specific findings; do NOT ask gene
|
||||
- Every question must reference specific findings from the report. Never ask generic "who is your audience?" questions.
|
||||
- Keep it to 2-4 questions maximum. Respect the user's time.
|
||||
- Offer concrete options, not open-ended prompts.
|
||||
- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Recommended Actions.
|
||||
- Skipping is allowed only when the report listed **fewer than 3 Priority Issues**. Count them; do not judge the findings "straightforward" by feel. At 3 or more, the questions are required.
|
||||
|
||||
**Final-question gate.** The user-visible response must either include the targeted questions or carry the literal line `Questions skipped: <reason>` naming the count that permitted the skip. Each question must include 2-3 concrete answer options tied to the actual critique findings. Do not end with only open-ended questions, and do not end with neither: stopping after the report, having asked nothing and printed no skip line, is the most common way this command fails.
|
||||
|
||||
### Recommended Actions
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Do not redesign. Preserve the reference's visual role, silhouette, palette, ligh
|
||||
|
||||
## Decision Comps
|
||||
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `comp` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Exclusions bind those claims, never a medium the card's own world has not excluded: a subject that lives in photographs keeps its photographs. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
|
||||
|
||||
## Input Contract
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Analyze what makes the design feel complex or cluttered:
|
||||
- What can be removed, hidden, or combined?
|
||||
- What's the 20% that delivers 80% of value?
|
||||
|
||||
If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
|
||||
If any of these are unclear from the codebase, do not guess. Ask the user directly to clarify what you cannot infer.
|
||||
|
||||
**CRITICAL**: Simplicity is not about removing features. It's about removing obstacles between users and their goals. Every element should justify its existence.
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ The same restraint applies to `workspace-context-inherited`. Inheritance is a de
|
||||
|
||||
- `workspace-platform-native-evidence` is the finding that matters most here: a workspace carrying native build files while inheriting a root record that resolves to web gets web guidance for its whole life and never loads [ios.md](ios.md) or [android.md](android.md). The repair is a child PRODUCT.md in that workspace, because one inherited record cannot hold two platforms.
|
||||
- `config-project-roots-match-nothing` means every `projectRoots` glob missed, so the repo root is silently standing in as the active project. A renamed workspace directory is the usual cause. Report the patterns and ask which directories they should name.
|
||||
- `config-invalid-build-path` and `config-build-path-unset` both concern one key, `buildPath` in `.impeccable/config.json` (or the gitignored `.impeccable/config.local.json`, which wins for that developer). It holds `comp` or `code` and sets whether new surfaces are built from a generated comp or straight in code. An unread value does not fall back to the opposite path, so a project meaning `code` has been building comp-led; report the exact value. The unset finding fires only where a project has done direction work and never recorded a preference, and the offer belongs in it only when image generation exists in your tool surface. Without image generation there is nothing to choose and nothing to say.
|
||||
- Use the `workspaces` table to show the user which apps carry their own context, which inherit, and which have none, before proposing any change.
|
||||
|
||||
## Opting out of the boot check
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user