Direction round: verdict-routed hand, MY PICK card, salience parity, Safer/Bolder registers (#531)

* Route the direction hand by verdict, add the pick card, enforce salience parity

The decision round previously rendered every dealt challenger as an equal
full card whatever the weighing said, so a world that fused poorly (an
underwater world dealt to a flower shop) sat at the same visual weight as
the assigned direction, and concept-level fusion had no surviving output.
Three changes, all presentation-layer; the dice, the assignment, and the
two-axis weighing are untouched:

- Verdict routing: the weighing closes with wins / competitive / declined
  per challenger, decided before any borrowing. Declined challengers render
  demoted (narrow, quiet, catalog art as a labeled thumb, "Adopt anyway"),
  reordered to the end of the deck by the page itself, still adoptable,
  never silently dropped. Donations return as named "raised by" lines on
  the assigned card: a declined challenger donates ambition and system
  discipline, never its clothes.

- The pick card: one card for the model's top-ranked grounded candidate
  when the dice assigned another, kicker MY PICK, honest familiarity risk
  on its face. One card, never a ranked list, never the lead position; the
  anti-menu rule survives with exactly this carve-out.

- Salience parity: a card's imagery weight is capped by the assigned
  card's. With a text-only assigned card (no image generation in the
  harness), full-bleed catalog heroes demote to labeled thumbs, so what
  looks important is the verdict's call, never rendering luck.

serve-question payload gains additive fields (verdict, kept, raised); old
payloads render unchanged. concept-seed's rendered instructions carry the
verdict/donation contract and the pick-card carve-out. Covered by two
Playwright tests in the new-work e2e suite (verdict routing + parity).

Design exploration and rationale were worked through with the maintainer;
research grounding is impeccable.style/research lessons 3-5.

AI-assisted change.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Add Safer/Bolder re-roll registers to the direction round

The re-roll gains the user's steering wheel on the familiar-to-bold axis.
The decision page renders two register buttons beside the plain re-roll
(payload: reroll: { registers: ["safer", "bolder"] }; booleans still work),
the answer carries the chosen register, and concept-seed gains --register.

The design constraint that shaped the implementation: a register changes
only what a round INSTRUCTS, never what it DEALT. The same key and reroll
count reproduce the same deal whatever the register, so the exclusion chain
never forks and the reproduction contract holds with no API change.

- bolder: the dealt foreign forms become the whole hand, every challenger a
  full card; the first-dealt challenger leads (assignment by deal order, so
  the dice still choose). The pick card sits out; the canon stays.
- safer: the round's dealt hand is spent unseen and stays excluded; the
  model presents its remaining conventional grounded candidates (at most
  three) plus the canon executed against named competitors. This is the one
  sanctioned lineup of the model's own ranked list, existing only by
  explicit user request. Works degraded (needs no catalog); bolder degrades
  to a plain grounded round, disclosed.

Registers are user steering, never the model's to pre-select. Covered by a
concept-seed unit test (same-deal invariant, validation) and a Playwright
test (button, answer field, REGISTER directive).

AI-assisted change.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Add the execution-contract round: comp-led or code-led, chosen after the direction

The build previously went comp-led for everyone, silently: a generated comp
led and the build chased it, which produces the boldest compositions and
also the measured worst-of-both-worlds failure (ambitious design landed
poorly, no motion, fix rounds after). Models already defect from it by
quietly skipping comp generation, which is unsanctioned code-led with no
contract to catch it. This makes the fork explicit and both paths
defection-proof:

- Comp-led: the comp is law and non-optional once chosen; visualize.md and
  the comp-is-king build phases run as today.
- Code-led: no comp of this page, skipped by contract rather than drift.
  The QUALITY BAR boards still calibrate finish, and the ambition moves
  into the written direction contract (FIRST VIEWPORT plus a named
  signature interaction and motion grammar), audited by the finish
  reviewer in behavior. Not a discount on commitment.

Placement: a second round on the same open table, right after the
direction lands. Sketches stay in the direction round (they pick the
world); comps are what code-led skips (they bind the composition). The
chosen world sets the default lead; the user flips freely; a standing
preference recorded in PRODUCT.md skips the round on later surfaces; with
no image generation there is no fork, code-led is the only path.

Mechanism: serve-question gains payload-level followup: true, which keeps
the detached server alive after a pick (exactly like re-roll), swaps the
page to the loading hand instead of goodbye, marks the answer with
followup: true so --wait keeps the table, and prints a FOLLOWUP OPEN
directive telling the agent to deliver the next round via --update.
Covered by a Playwright test driving the full two-round flow.

AI-assisted change.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: address PR review bot findings

- Degraded safer register no longer contradicts itself (greptile,
  Copilot, cursor): the degraded template previously said "the assigned
  index is suspended; the user picks" and then emitted ASSIGNED INDEX,
  the mandatory build instruction, and the restated footer anyway. The
  degraded safer path now suppresses the assignment machinery entirely,
  matching the non-degraded safer round, and restates the user-picks
  behavior for truncated readers instead.
- A declined card's declared sketch no longer renders a full media face
  (Copilot): the renderer ignores sketch slots on declined cards
  outright, so a stray sketch cannot buy back the salience the verdict
  took away.
- Bolder rounds no longer carry the generic weighing instruction
  (cursor): it measures against the assigned grounded direction, which
  the bolder register suspends; a leader-relative variant weighs the
  fused challengers against the first-dealt leader instead.

All three pinned by new assertions in tests/concept-seed.test.mjs and
tests/new-work-e2e.test.mjs.

AI-assisted change.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: followup never arms the loading hand in blocking serve mode

cursor[bot] caught a client/server disagreement: the page interpolated its
FOLLOWUP constant from the payload alone, so a followup: true payload served
in blocking mode (no --start) would leave the browser on a loading hand that
nothing resolves, since a blocking server exits on any pick and has no
update channel. The page constant is now armed only when the server is
detached, blocking rounds get the goodbye screen as before, and new-work.md
states that followup belongs only on a detached round; blocking and
structured-tool channels run the build-path round as its own second
question. Pinned in tests/serve-question.test.mjs.

AI-assisted change.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Add card-kind choice telemetry and the bolder routing disambiguation

The choice ping previously fired only when a dealt catalog challenger won,
so pick-share and canon-share had no denominator and the decision page's
new spectrum could not be measured. The ping now fires once per resolved
attended round on API-dealt rolls: --kind names which card class won
(assigned / pick / challenger / canon), --chosen carries the catalog id
only when a dealt challenger won, and --register rides along when the
round came from a steered hand. Grounded candidates' names never leave the
machine (the ping carries the kind alone), the legacy id-only shape stays
valid, and DO_NOT_TRACK / IMPECCABLE_NO_TELEMETRY still disable the ping
entirely. The seed's TELEMETRY block teaches the new invocation.

Also the naming-collision guard: "bolder" said while a direction round is
open routes to the Bolder hand register, never the bolder refinement
command; one line each in bolder.md and new-work.md.

The /api/chosen field additions land in a sister impeccable-site PR; the
API ignores unknown fields meanwhile, so this is safe to ship first.

AI-assisted change.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: ping test survives a DO_NOT_TRACK shell

cursor[bot]: the pingChosen unit test cleared only IMPECCABLE_NO_TELEMETRY,
so a developer shell with DO_NOT_TRACK set failed the success-path
assertions. The test now clears both, restores prior values in finally,
and passes under DO_NOT_TRACK=1.

AI-assisted change.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-08-08 14:17:21 -07:00
committed by GitHub
co-authored by Claude Code
parent aee6ce9352
commit c70bcbf6b4
8 changed files with 598 additions and 75 deletions
+167 -28
View File
@@ -31,6 +31,16 @@
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
* fresh assigned index, challengers, and compositions. One base key therefore
* reproduces the entire chain of rounds.
* - REGISTER (--register safer|bolder): the user's steering on the
* familiar-to-bold axis, applied to a re-roll round. A register changes
* only what this round instructs, never what it dealt: the same key and
* reroll count reproduce the same deal whatever the register, so the
* exclusion chain never forks. bolder presents the dealt foreign forms
* as the whole hand (first-dealt leads, dice-assigned by deal order);
* safer spends the dealt hand unseen and presents the familiar register,
* the model's conventional grounded candidates plus the canon against
* named competitors, the one sanctioned lineup of the model's own list.
* Registers are user-requested, never pre-selected by the model.
* - RATINGS: the reviewer's approval ratings weight the challenger draw
* (3-star doubles the odds, 1-star sits out); the approved pool itself
* is unchanged.
@@ -41,7 +51,9 @@
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --scope direction
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1 --register bolder
* node scripts/concept-seed.mjs --chosen <challenger-id> --kind challenger --from <key> --scope direction
* node scripts/concept-seed.mjs --kind assigned --from <key> --scope direction
*
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
@@ -62,8 +74,13 @@
* Challenger data resolves in order: a local catalog directory (the private
* service repo, evals, and tests set IMPECCABLE_CATALOG_DIR), then the roll
* API at impeccable.style, then a degraded assignment-only seed when both are
* unavailable. --chosen sends the anonymous choice ping for API-dealt rolls;
* DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables it.
* unavailable. The anonymous choice ping fires once per resolved attended
* round on API-dealt rolls: --kind names which card class won (assigned,
* pick, challenger, canon) so share metrics have a denominator, --chosen
* carries the catalog id when a dealt challenger won, and --register rides
* along when the round came from a steered hand. Grounded candidates' names
* never leave the machine. DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables
* the ping entirely.
*
* Env vars:
* IMPECCABLE_CONCEPT_SEED — same as --from; for reproducible eval runs.
@@ -172,17 +189,35 @@ function telemetryDisabled() {
return Boolean(process.env.IMPECCABLE_NO_TELEMETRY || process.env.DO_NOT_TRACK);
}
// Anonymous choice ping: records only that a dealt world was selected.
// Anonymous choice ping: one per resolved attended direction round. kind
// says which card class won (assigned / pick / challenger / canon), so
// pick-share and canon-share have a denominator; chosenId rides along only
// when a dealt catalog world won, and register only when the round came from
// a steered hand. Grounded candidates' names never leave the machine: they
// are derived from the user's project, so the ping carries the kind alone.
// Fire-and-forget; never fails the caller.
export async function pingChosen({ chosenId, key, scope, mode }) {
if (telemetryDisabled() || !chosenId) return false;
const PING_KINDS = new Set(['assigned', 'pick', 'challenger', 'canon']);
export async function pingChosen({ chosenId, key, scope, mode, kind, register }) {
if (telemetryDisabled()) return false;
if (kind && !PING_KINDS.has(kind)) return false;
if (register && register !== 'safer' && register !== 'bolder') return false;
// Legacy shape: a bare challenger id with no kind stays a valid ping.
if (!chosenId && !kind) return false;
if ((kind === 'challenger' || !kind) && !chosenId) return false;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
await fetch(`${API_BASE}/chosen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chosenId, key, scope, mode }),
body: JSON.stringify({
...(chosenId ? { chosenId } : {}),
key,
scope,
mode,
...(kind ? { kind } : {}),
...(register ? { register } : {}),
}),
signal: controller.signal,
});
return true;
@@ -260,6 +295,7 @@ export function renderConceptSeed({
scope = 'surface',
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
register = null,
mode = null,
grain = null,
platform = null,
@@ -273,6 +309,15 @@ export function renderConceptSeed({
if (!Number.isInteger(reroll) || reroll < 0) {
throw new Error('concept-seed: --reroll must be a non-negative integer');
}
if (register !== null && register !== 'safer' && register !== 'bolder') {
throw new Error('concept-seed: --register must be safer or bolder');
}
if (register !== null && reroll < 1) {
throw new Error('concept-seed: --register steers a re-roll round; pass --reroll <n> with it');
}
if (register !== null && scope !== 'direction') {
throw new Error('concept-seed: --register applies to direction rounds only');
}
if (mode !== null && !SEED_MODES.has(mode)) {
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
}
@@ -326,6 +371,7 @@ export function renderConceptSeed({
scope,
key,
reroll,
register,
mode,
grain,
platform,
@@ -357,7 +403,11 @@ export function renderConceptSeed({
survive the current task plus navigation, quiet and dense content,
interaction and state, and a substantially different future surface. In an
attended run, present the assigned direction fully committed and offer
re-roll; never present a ranked lineup to choose from. Re-roll yourself only
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
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,
@@ -374,7 +424,16 @@ export function renderConceptSeed({
conflicts. Weigh the fused result against the assigned direction on exactly
two axes, audience identification and product clarity. Losing to strong
grounded material is a valid outcome; beating a thin or tool-monoculture
list is the point. A fused challenger that wins both axes becomes the build.`
list is the point. A fused challenger that wins both axes becomes the build.
Close the weighing with a verdict per challenger, decided before any
borrowing is considered: wins (beats the assigned direction on both axes),
competitive (holds one axis), 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, never
the challenger's clothes; one world owns the page. Write each raise as its
own named line on the presented direction, and carry every verdict, kept
line, and raise into the decision page payload.`
: `A challenger wins only when its fused result beats the grounded list on
audience identification and product clarity. It may change task topology or
interaction, but never the committed visual identity.`;
@@ -399,8 +458,39 @@ Ambitious motion, spatial media, or interaction is welcome when it strengthens
the product without weakening semantics, performance, or fallback behavior.`;
if (!data) {
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount})
ASSIGNED INDEX: ${buildIndex}
// A degraded roll can still serve the safer register, which needs no
// catalog at all: the assignment machinery is suppressed entirely, the
// same as the non-degraded safer round, because emitting both "the user
// picks" and a mandatory numbered build order hands the model two
// contradicting instructions and the mandatory one tends to win. The
// bolder register is exactly the thing degradation took away, so it
// falls back to a plain grounded round, disclosed.
const degradedHeader = `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount})`;
if (register === 'safer') {
return `${degradedHeader}
SAFER REGISTER (user-requested): the assigned index is suspended this
round; the user picks, and no candidate is mandated. Present the familiar
register: your remaining grounded candidates from the conventional end, at
most three, as full cards with an honest risk line each, plus the canon
executed against two or three named competitors. This is the one sanctioned
lineup of your own ranked candidates; it exists only by this explicit
request. When the user voices a standing preference for it, record a brand
commitment in PRODUCT.md.
${authorityInstruction}
A user- or brief-pinned decision beats the roll, always.
REGISTER (restated for truncated readers): safer, user-requested; the
assigned index is suspended this round and the user picks; seed key ${key}.
`;
}
const degradedRegister = register === 'bolder'
? `BOLDER REGISTER UNAVAILABLE: bolder deals foreign forms, and this roll ran
degraded with no catalog and no roll service, so there is nothing bold to
deal. Tell the user, then run this round as a plain grounded re-roll; the
assignment below applies.
`
: '';
return `${degradedHeader}
${degradedRegister}ASSIGNED INDEX: ${buildIndex}
${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.
@@ -471,34 +561,76 @@ structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
and challenger alike, is eliminated and may not return reworded. Derive
? `RE-ROLL ROUND ${reroll}${register ? ` (${register.toUpperCase()} REGISTER, user-requested)` : ''}: every candidate presented in earlier rounds, grounded
and challenger alike, is eliminated and may not return reworded.${register ? '' : ` Derive
genuinely new grounded candidates from unexplored angles before judging
these fresh challengers.\n`
these fresh challengers.`}\n`
: '';
// A register swaps the round's presentation, never its deal: the assigned
// index and challenger fetch stay identical so the chain reproduces, and
// only the instructions change.
const saferBlock = `SAFER REGISTER: the user asked for the familiar end of the spectrum, so this
round's dealt hand is spent unseen, stays excluded from future rounds, and
is not printed. The assigned index is suspended this round; the user picks. Present the familiar register: your remaining grounded
candidates from the conventional end, at most three, as full cards with an
honest risk line each, plus the canon executed against two or three named
competitors. This is the one sanctioned lineup of your own ranked
candidates; it exists only by this explicit request. When the user voices a
standing preference for it, record a brand commitment in PRODUCT.md.`;
const bolderBlock = `BOLDER REGISTER: the user asked for foreign forms at full commitment, so no
grounded direction is presented this round and the assigned index is
suspended. The hand is every dealt challenger below, each fused with the
product and presented as a full card; the FIRST dealt challenger leads, an
assignment by deal order, so the dice still choose. Verdicts and donations
apply between the challengers, weighed against the leader. The pick card
sits out; the canon stays, as always.`;
const telemetryBlock = data.source === 'api'
? `TELEMETRY: if the resolved direction uses one of these challengers, rerun
this script once with --chosen <challenger-id> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''}
after resolution. The ping is anonymous (chosen id only) and is skipped
automatically when DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY is set.\n`
? `TELEMETRY: after the user's choice resolves, rerun this script once with
--kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''},
adding --chosen <challenger-id> when a dealt challenger won and keeping
--register <safer|bolder> when the resolved round came from a steered hand.
One ping per resolved attended round. The ping is anonymous, the card kind
plus the catalog id when one won; your grounded candidates' names never
leave the machine, and the ping is skipped automatically when DO_NOT_TRACK
or IMPECCABLE_NO_TELEMETRY is set.\n`
: '';
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}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
${rerollBlock}ASSIGNED INDEX: ${buildIndex}
const assignedBlock = register === null
? `ASSIGNED INDEX: ${buildIndex}
${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.
CHALLENGERS:
the user or the brief. Never expose assignment metadata in user-facing labels.`
: register === 'safer' ? saferBlock : bolderBlock;
// A bolder round has no assigned grounded direction, so the generic
// weighing instruction (which measures against the assignment) would
// contradict the register; the bolder variant weighs against the leader.
const bolderChallengerInstruction = `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 every fused challenger against the fused LEADER, the first
dealt, on exactly two axes, audience identification and product clarity;
verdicts and donations apply between the challengers, and one that beats
the leader on both axes presents as the hand's strongest alternate.`;
const roundChallengerInstruction = register === 'bolder' ? bolderChallengerInstruction : challengerInstruction;
const challengerSection = register === 'safer'
? ''
: `CHALLENGERS:
${data.challengers.map(renderChallenger).join('\n')}
${compositionBlock}${challengerInstruction}
${compositionBlock}${roundChallengerInstruction}
When you can view images, open the QUALITY BAR board and hero for any
challenger you weigh seriously and for the world you build. They exist as a
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.
${authorityInstruction}
`;
const restated = register === null
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
${buildIndex} of your own grounded list; 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)
${rerollBlock}${assignedBlock}
${challengerSection}${authorityInstruction}
${richnessInstruction}
${telemetryBlock}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}.
${restated}
`;
}
@@ -507,19 +639,25 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
const rerollIdx = args.indexOf('--reroll');
const registerIdx = args.indexOf('--register');
const modeIdx = args.indexOf('--mode');
const grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
const kindIdx = args.indexOf('--kind');
try {
if (chosenIdx !== -1) {
if (chosenIdx !== -1 || kindIdx !== -1) {
// Choice ping: always exits 0, telemetry must never fail a design flow.
// --kind alone pings a non-challenger outcome (assigned/pick/canon);
// --chosen alone stays the legacy challenger-win ping.
const sent = await pingChosen({
chosenId: args[chosenIdx + 1],
chosenId: chosenIdx !== -1 ? args[chosenIdx + 1] : undefined,
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
mode: modeIdx !== -1 ? args[modeIdx + 1] : undefined,
kind: kindIdx !== -1 ? args[kindIdx + 1] : undefined,
register: registerIdx !== -1 ? args[registerIdx + 1] : undefined,
});
process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n');
} else {
@@ -542,6 +680,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
? args[fromIdx + 1]
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
register: registerIdx !== -1 ? args[registerIdx + 1] : null,
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
+161 -34
View File
@@ -29,6 +29,17 @@
* "materials": ["letterpress", "newsprint"], // optional, rendered as tags
* "viewport": "one line: the first-viewport composition", // optional
* "case": "one line: the fusion verdict, honest", // optional
* "verdict": "competitive", // optional routing tier: "wins" |
* // "competitive" | "declined". Declined cards
* // render demoted after the full cards:
* // narrow, quiet, catalog art as a labeled
* // thumb, "Adopt anyway" instead of "Build
* // this". Still choosable; never deleted.
* "kept": "one line: what the direction kept from this declined world",
* "raised": [ { "from": "challenger-x", "raise": "one line" } ],
* // assigned card only: donations taken from
* // declined challengers, rendered as named
* // raise lines under the identity row
* "risk": "one line: the honest risk", // optional
* "body": "fallback prose when the structured fields are absent",
* "sketch": ".impeccable/sketches/assigned.webp", // optional; may not exist
@@ -41,13 +52,25 @@
* }, ...
* ],
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
* // or { "registers": ["safer", "bolder"] } to add
* // the register steers beside it: the answer then
* // carries "register" and the agent re-runs
* // concept-seed with --register <value>
* "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, ...);
* // 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
* "steer": true, // adds a free-text steer field returned with any answer
* "followup": true // this round's pick is not terminal: the server
* // stays open awaiting --update with the next
* // round (detached mode only), the page shows a
* // loading hand instead of goodbye, and the
* // answer carries followup:true so --wait knows
* // to keep the table. Use it when a decision has
* // a known second half, e.g. direction first,
* // then the execution contract.
* }
*
* Options render as large cards: the sketch leads when present, with the
@@ -129,6 +152,12 @@ function printAnswer(raw) {
if (a.optionId === 'canon') {
console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.');
}
if (a.optionId === 'reroll' && a.register) {
console.log(`REGISTER: the user steered the next hand to the ${a.register} register. Re-run concept-seed with the same key, the next --reroll round, and --register ${a.register}, then follow what it prints; the register is the user's steering, never yours to pre-select.`);
}
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.');
}
} catch { /* raw answer */ }
}
@@ -144,15 +173,17 @@ if (hasFlag('schema')) {
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.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', case: 'Fuses cleanly: releases map to numbered pages.', sketch: '.impeccable/sketches/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
{ id: '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/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'MY PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the days ballad sheet.', risk: 'Also the direction most runs in this category land on.', sketch: '.impeccable/sketches/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.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', sketch: '.impeccable/sketches/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', 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: true,
reroll: { registers: ['safer', 'bolder'] },
canon: true,
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/canon.webp' },
steer: true,
}, null, 2));
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
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 verdicts 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. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. 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.');
process.exit(0);
}
@@ -196,12 +227,16 @@ if (hasFlag('wait')) {
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
printAnswer(collected);
// A re-roll keeps the table open: the server stays alive awaiting --update,
// so only the answer file is consumed. Terminal choices clean up fully.
let isRerollAnswer = false;
try { isRerollAnswer = JSON.parse(collected).optionId === 'reroll'; } catch { /* treat as terminal */ }
// A re-roll or a followup-round pick keeps the table open: the server stays
// alive awaiting --update, so only the answer file is consumed. Terminal
// choices clean up fully.
let keepsTableOpen = false;
try {
const parsedAnswer = JSON.parse(collected);
keepsTableOpen = parsedAnswer.optionId === 'reroll' || parsedAnswer.followup === true;
} catch { /* treat as terminal */ }
try { fs.rmSync(answerFile(key)); } catch { /* already gone */ }
if (!isRerollAnswer) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
if (!keepsTableOpen) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
process.exit(0);
}
@@ -302,6 +337,11 @@ function loadRound(json) {
sketchSrc: sketchSrc(option.sketch),
});
options = parsed.options.map(decorate);
// The verdict routes rendering: full cards first, declined cards demoted to
// the end of the deck in their own payload order. The reorder happens here
// so a payload that interleaves them still renders the weighing's shape.
const declined = options.filter((o) => o.verdict === 'declined');
if (declined.length) options = [...options.filter((o) => o.verdict !== 'declined'), ...declined];
// The standing exit as a full card: same anatomy, reserved id, rendered
// subordinate by the page. Without it, canon stays the quiet footer action.
if (parsed.canonCard && typeof parsed.canonCard === 'object') {
@@ -322,7 +362,19 @@ function page() {
// and material tags give a text-only direction an immediate identity that
// 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 hasMedia = (option) => Boolean(option.sketchSrc || option.heroSrc || option.boardSrc);
const demoted = (option) => option.verdict === 'declined';
// 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)));
// 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.
@@ -338,6 +390,19 @@ function page() {
idBits.push(option.materials.slice(0, 4).map((m) => `<span class="tag">${esc(m)}</span>`).join(''));
}
if (idBits.length) rows.push(`<div class="identity">${idBits.join('')}</div>`);
// Donations from declined challengers render as named raise lines: the
// assigned card arrives already raised by the hand it beat, and the raise
// is readable, because a raise nobody can read did not happen.
if (Array.isArray(option.raised) && option.raised.length) {
const nameOf = (id) => options.find((o) => o.id === id)?.label || String(id ?? '');
rows.push(`<div class="raises">${option.raised.slice(0, 4).map((r) => `<p class="raise"><span class="fact-label">Raised by ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`).join('')}</div>`);
}
// Demoted art stays reachable as a labeled thumb: the catalog world
// explains where the direction comes from without buying it back the
// salience the verdict took away.
if (thumbOnly(option)) {
rows.push(`<figure class="inspo" title="Inspiration: the world this direction draws from. Your page will not look like this image."><img src="${esc(option.heroSrc || option.boardSrc)}" alt=""><figcaption>inspired by</figcaption></figure>`);
}
// 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.
@@ -348,6 +413,7 @@ function page() {
} else {
rows.push(fact('First viewport', option.viewport));
rows.push(fact('The case', option.case));
rows.push(fact('Kept', option.kept));
rows.push(fact('Risk', option.risk));
}
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
@@ -357,6 +423,7 @@ function page() {
const backFacts = (option) => [
fact('First viewport', option.viewport),
fact('The case', option.case),
fact('Kept', option.kept),
fact('Risk', option.risk),
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
].filter(Boolean).join('\n ');
@@ -366,7 +433,10 @@ function page() {
<figcaption>inspiration</figcaption>
</figure>` : '';
const details = hasBack(option) ? flipChip('Details') : '';
if (option.sketchSrc) {
// Thumb-only art renders inside the body via anatomy(), never as a face,
// and a declined card's sketch 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">sketching&hellip;</span></div>
<img class="sketch" alt="" hidden>
@@ -385,17 +455,18 @@ function page() {
}
return '';
};
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' : ''}" 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)}">
<div class="card-inner">
<div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
${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)}
<div class="body">
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
<h2>${esc(option.label)}</h2>
${anatomy(option)}
<button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
<button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
</div>
</div>
${hasBack(option) ? `<div class="face back${index === 0 ? ' lead' : ''}">
@@ -406,7 +477,7 @@ function page() {
<div class="body back-body">
${option.boardSrc ? `<p class="tier">The full read &middot; ${esc(option.label)}</p>` : ''}
${backFacts(option)}
<button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
<button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
</div>
</div>` : ''}
</div>
@@ -585,6 +656,27 @@ function page() {
/* The generic .media img display:block would defeat [hidden] and float an
empty block over the shimmer; an unloaded sketch 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
the action reads "Adopt anyway". Adoptable, never deleted: the demoted
row is the hand's proof of judgment. */
.grid > .card.declined { flex: 0 0 clamp(15rem, 21vw, 21rem); }
.card.declined .face { background: var(--ks-graphite); }
.card.declined:hover .face { border-color: var(--ks-text-faint); }
.card.declined h2 { font-size: 1rem; color: var(--ks-text); }
.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); }
/* 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; }
.raise .fact-label { color: var(--ks-patina); }
/* The standing exit as a card: present with full anatomy, never dressed as a
contender. Graphite instead of kinpaku, and it never takes the lead ring. */
.card.canon .face { border-color: var(--ks-rule); background: var(--ks-graphite); }
@@ -597,9 +689,14 @@ function page() {
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
#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); }
#reroll { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
#reroll svg { width: 15px; height: 15px; }
.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; }
.reroll-btn[disabled] { opacity: .4; cursor: default; }
/* The register steers read quieter than the plain roll: they are exits from
the current register, not the round's main verbs. */
#reroll-safer, #reroll-bolder { color: var(--ks-text-muted); min-height: 38px; }
#reroll-safer:hover, #reroll-bolder:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
/* The quiet exit: always available, never argued with, visually subordinate
to the dealt cards and the re-roll so it reads as the user's own door,
not a recommendation. */
@@ -644,16 +741,33 @@ function page() {
</main>
<footer>
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
${payload.reroll ? '<button id="reroll"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg><span>Re-roll</span></button>' : ''}
${(() => {
if (!payload.reroll) return '';
const die = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
const registers = Array.isArray(payload.reroll.registers) ? payload.reroll.registers.filter((r) => r === 'safer' || r === 'bolder') : [];
// The registers are the user's steering wheel on the familiar-to-bold
// axis; the plain re-roll sits between them so the spatial order matches
// the axis it names.
const safer = registers.includes('safer') ? '<button class="reroll-btn" id="reroll-safer" title="Deal the familiar register: conventional grounded directions plus the category standard against named competitors"><span>&larr; Safer hand</span></button>' : '';
const bolder = registers.includes('bolder') ? '<button class="reroll-btn" id="reroll-bolder" title="Deal foreign forms only, at full commitment"><span>Bolder hand &rarr;</span></button>' : '';
return `${safer}<button class="reroll-btn" id="reroll">${die}<span>Re-roll</span></button>${bolder}`;
})()}
${payload.canon && !payload.canonCard ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
</footer>
<script>
const steer = () => document.getElementById('steer')?.value || '';
// A followup round's pick keeps the tab: the next round arrives via
// --update, so the page shows the loading hand instead of goodbye. Detached
// mode only, and the page must agree with the server: a blocking server
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
beat();
setInterval(beat, 5000);
async function answer(optionId) {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
if (FOLLOWUP) { await awaitNextRound(); return; }
document.body.innerHTML = '<div class="done"><svg viewBox="0 0 24 24" width="38" height="38" fill="oklch(84% 0.19 80.46)" 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>Choice recorded. The agent is resuming; you can close this tab.</div>';
}
document.querySelectorAll('button.choose').forEach(b => b.addEventListener('click', () => answer(b.dataset.id)));
@@ -759,15 +873,15 @@ function page() {
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
else img.addEventListener('error', () => artFailed(img), { once: true });
});
// A broken inspiration PIP just leaves; nothing depends on it.
document.querySelectorAll('.pip img').forEach(img => {
const gone = () => img.closest('.pip')?.remove();
// A broken inspiration PIP or thumb just leaves; nothing depends on it.
document.querySelectorAll('.pip img, .inspo img').forEach(img => {
const gone = () => img.closest('.pip, .inspo')?.remove();
if (img.complete && img.naturalWidth === 0) gone();
else img.addEventListener('error', gone, { once: true });
});
// Inspiration PIP opens the full catalog card in the lightbox.
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
// Inspiration PIP or body thumb opens the full catalog card in the lightbox.
document.querySelectorAll('.pip, .inspo').forEach(p => p.addEventListener('click', (e) => {
e.stopPropagation();
const img = p.querySelector('img');
if (!img) return;
@@ -814,7 +928,7 @@ function page() {
const ambient = document.getElementById('ambient');
document.querySelectorAll('.card').forEach(card => {
card.addEventListener('mouseenter', () => {
const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img');
const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img') || card.querySelector('.face.front .inspo img');
if (!art || !art.getAttribute('src')) return;
ambient.style.backgroundImage = 'url("' + art.getAttribute('src') + '")'; ambient.style.opacity = '1';
});
@@ -862,8 +976,11 @@ function page() {
lightbox.addEventListener('click', closeLightbox);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
document.getElementById('reroll')?.addEventListener('click', async () => {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
const dealAgain = async (register) => {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await awaitNextRound();
};
async function awaitNextRound() {
const grid = document.querySelector('.grid');
const cardsNow = [...grid.querySelectorAll('.card')];
const g = grid.getBoundingClientRect();
@@ -880,14 +997,17 @@ function page() {
}
const cardHeight = cardsNow[0] ? cardsNow[0].getBoundingClientRect().height : 0;
grid.innerHTML = cardsNow.map(() => '<article class="card skeleton"' + (cardHeight ? ' style="height:' + cardHeight + 'px"' : '') + '><div class="card-inner"><div class="face front"><div class="media"><div class="shimmer"></div></div><div class="body"><div class="line tier w40"></div><div class="line title w70"></div><div class="line w90"></div><div class="line w80"></div><div class="line w60"></div><div class="line button"></div></div></div></div></article>').join('');
document.getElementById('reroll')?.setAttribute('disabled', '');
document.querySelectorAll('.reroll-btn').forEach(b => b.setAttribute('disabled', ''));
const poll = setInterval(async () => {
try {
const status = await (await fetch('/next-status')).json();
if (status.ready) { clearInterval(poll); location.reload(); }
} catch { /* server briefly busy */ }
}, 1200);
});
}
document.getElementById('reroll')?.addEventListener('click', () => dealAgain());
document.getElementById('reroll-safer')?.addEventListener('click', () => dealAgain('safer'));
document.getElementById('reroll-bolder')?.addEventListener('click', () => dealAgain('bolder'));
</script>`;
}
@@ -944,22 +1064,29 @@ const server = http.createServer((req, res) => {
let parsed = {};
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
const chosen = options.find((o) => o.id === parsed.optionId);
const isReroll = parsed.optionId === 'reroll';
// A followup round's pick is not terminal: the table stays open for the
// next round (--update), exactly like a re-roll. Detached mode only;
// the blocking mode has no update channel, so its picks stay terminal.
const followupOpen = Boolean(detachedKey) && payload.followup === true && !isReroll;
const answer = JSON.stringify({
optionId: parsed.optionId ?? null,
steer: parsed.steer ?? '',
...(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 } : {}),
});
const isReroll = parsed.optionId === 'reroll';
if (detachedKey) {
fs.mkdirSync(QUESTION_DIR, { recursive: true });
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
} else {
printAnswer(answer);
}
// A re-roll in detached mode keeps the table open: the client shows a
// loading hand and reloads when --update delivers the next round.
if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150);
// A re-roll or followup pick in detached mode keeps the table open: the
// client shows a loading hand and reloads when --update delivers the
// next round.
if (!((isReroll || followupOpen) && detachedKey)) setTimeout(() => process.exit(0), 150);
});
return;
}