quiver-cli 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/dist/cli.js +237 -10
  2. package/package.json +1 -1
  3. package/template/.agents/skills/agent-browser/SKILL.md +1 -0
  4. package/template/.agents/skills/apps/skybridge/SKILL.md +2 -0
  5. package/template/.agents/skills/data/prisma-cli/SKILL.md +25 -7
  6. package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +27 -0
  7. package/template/.agents/skills/data/prisma-cli/references/complete.md +22 -0
  8. package/template/.agents/skills/data/prisma-cli/references/db-push.md +2 -0
  9. package/template/.agents/skills/data/prisma-cli/references/init.md +3 -0
  10. package/template/.agents/skills/data/prisma-cli/references/mcp.md +2 -1
  11. package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +1 -1
  12. package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +2 -0
  13. package/template/.agents/skills/data/prisma-client-api/SKILL.md +1 -1
  14. package/template/.agents/skills/data/prisma-client-api/references/constructor.md +13 -0
  15. package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +4 -0
  16. package/template/.agents/skills/design/impeccable/SKILL.md +5 -5
  17. package/template/.agents/skills/design/impeccable/reference/android.md +6 -0
  18. package/template/.agents/skills/design/impeccable/reference/animate.md +3 -0
  19. package/template/.agents/skills/design/impeccable/reference/bolder.md +3 -1
  20. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +2 -0
  21. package/template/.agents/skills/design/impeccable/reference/critique.md +23 -5
  22. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +15 -68
  23. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +13 -12
  24. package/template/.agents/skills/design/impeccable/reference/distill.md +1 -1
  25. package/template/.agents/skills/design/impeccable/reference/doctor.md +1 -0
  26. package/template/.agents/skills/design/impeccable/reference/document.md +1 -1
  27. package/template/.agents/skills/design/impeccable/reference/extract.md +1 -1
  28. package/template/.agents/skills/design/impeccable/reference/hooks.md +17 -11
  29. package/template/.agents/skills/design/impeccable/reference/init.md +9 -3
  30. package/template/.agents/skills/design/impeccable/reference/ios.md +6 -0
  31. package/template/.agents/skills/design/impeccable/reference/new-work.md +69 -29
  32. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -1
  33. package/template/.agents/skills/design/impeccable/reference/polish.md +13 -5
  34. package/template/.agents/skills/design/impeccable/reference/quieter.md +1 -1
  35. package/template/.agents/skills/design/impeccable/reference/routing.md +1 -1
  36. package/template/.agents/skills/design/impeccable/reference/visualize.md +21 -22
  37. package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +1022 -0
  38. package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +391 -0
  39. package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +513 -0
  40. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +297 -41
  41. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +10 -19
  42. package/template/.agents/skills/design/impeccable/scripts/context.mjs +124 -9
  43. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +279 -19
  44. package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +121 -0
  45. package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +1 -0
  46. package/template/.agents/skills/design/impeccable/scripts/detect.mjs +9 -0
  47. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +192 -11
  48. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +10 -16
  49. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +339 -11
  50. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +1482 -722
  51. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +1 -0
  52. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +64 -2
  53. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +580 -29
  54. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +62 -7
  55. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +31 -7
  56. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +23 -22
  57. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +18 -0
  58. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +471 -370
  59. package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +474 -2
  60. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +17 -2
  61. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +14 -21
  62. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +81 -48
  63. package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +457 -0
  64. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +219 -12
  65. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +93 -15
  66. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +41 -19
  67. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +507 -117
  68. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +10 -9
  69. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +40 -1
  70. package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +120 -82
  71. package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +564 -0
  72. package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +130 -0
  73. package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +246 -0
  74. package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +306 -0
  75. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +41 -59
  76. package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +5 -2
  77. package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +37 -0
  78. package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +26 -0
  79. package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +281 -0
  80. package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +194 -0
  81. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +26 -19
  82. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +31 -3
  83. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +93 -17
  84. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +9 -11
  85. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +31 -2
  86. package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +139 -0
  87. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +10 -2
  88. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +26 -2
  89. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +15 -27
  90. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +4 -25
  91. package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +75 -0
  92. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +21 -37
  93. package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +242 -0
  94. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +62 -23
  95. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +68 -112
  96. package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +132 -15
  97. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +2 -42
  98. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +5 -4
  99. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +56 -19
  100. package/template/.agents/skills/design/impeccable/scripts/live.mjs +12 -37
  101. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +8 -5
  102. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +1006 -155
  103. package/template/.agents/skills/design/shadcn/rules/chat.md +26 -0
  104. package/template/.agents/skills/hono/SKILL.md +17 -3
  105. package/template/.agents/skills/integrations/langfuse/SKILL.md +8 -4
  106. package/template/.agents/skills/integrations/langfuse/references/cli.md +1 -1
  107. package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +35 -0
  108. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +9 -10
  109. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +8 -6
  110. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +34 -0
  111. package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +65 -0
  112. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +20 -40
  113. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -30
  114. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +3 -1
  115. package/template/.agents/skills/supabase/CHANGELOG.md +7 -0
  116. package/template/.agents/skills/supabase/SKILL.md +5 -1
  117. package/template/.agents/skills/writing/humanizer/LICENSE +21 -0
  118. package/template/.agents/skills/writing/humanizer/README.md +209 -0
  119. package/template/.agents/skills/writing/humanizer/SKILL.md +173 -338
  120. package/template/.agents/upstreams.json +21 -22
  121. package/template/.agents/skills/design/impeccable/scripts/live/ui-core.mjs +0 -180
@@ -29,28 +29,53 @@
29
29
  * "materials": ["letterpress", "newsprint"], // optional, rendered as tags
30
30
  * "viewport": "one line: the first-viewport composition", // optional
31
31
  * "case": "one line: the fusion verdict, honest", // optional
32
+ * "verdict": "competitive", // optional routing tier: "wins" |
33
+ * // "competitive" | "declined". Declined cards
34
+ * // render demoted after the full cards:
35
+ * // narrow, quiet, catalog art as a labeled
36
+ * // thumb, "Adopt anyway" instead of "Build
37
+ * // this". Still choosable; never deleted.
38
+ * "kept": "one line: what the direction kept from this declined world",
39
+ * "raised": [ { "from": "challenger-x", "raise": "one line" } ],
40
+ * // assigned card only: donations taken from
41
+ * // declined challengers, rendered as named
42
+ * // raise lines under the identity row
32
43
  * "risk": "one line: the honest risk", // optional
33
44
  * "body": "fallback prose when the structured fields are absent",
34
- * "sketch": ".impeccable/sketches/assigned.webp", // optional; may not exist
35
- * // yet: the page shimmer-waits and polls the
36
- * // slot until the file lands, so serve first
37
- * // and generate after
45
+ * "comp": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
46
+ * // full-fidelity direction comp (the legacy
47
+ * // key "sketch" is accepted as an alias). May
48
+ * // not exist yet: the page shimmer-waits and
49
+ * // polls the slot until the file lands, so
50
+ * // serve first and generate after
38
51
  * "hero": "https://... or /abs/path.webp", // optional inspiration image;
39
- * // rides picture-in-picture when a sketch exists
52
+ * // rides picture-in-picture when a comp exists
40
53
  * "board": "https://... or /abs/path.webp" // optional secondary image
41
54
  * }, ...
42
55
  * ],
43
56
  * "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
57
+ * // or { "registers": ["safer", "bolder"] } to add
58
+ * // the register steers beside it: the answer then
59
+ * // carries "register" and the agent re-runs
60
+ * // concept-seed with --register <value>
44
61
  * "canon": true, // adds the "Play it straight" standing exit;
45
62
  * // direction rounds only (returns {"optionId":"canon"})
46
63
  * "canonCard": { ... }, // optional: the standing exit as a full card with the
47
- * // same anatomy (label, thesis, palette, sketch, ...);
64
+ * // same anatomy (label, thesis, palette, comp, ...);
48
65
  * // rendered last and visually subordinate. Without it,
49
66
  * // canon stays a quiet footer action.
50
- * "steer": true // adds a free-text steer field returned with any answer
67
+ * "steer": true, // adds a free-text steer field returned with any answer
68
+ * "followup": true // this round's pick is not terminal: the server
69
+ * // stays open awaiting --update with the next
70
+ * // round (detached mode only), the page shows a
71
+ * // loading hand instead of goodbye, and the
72
+ * // answer carries followup:true so --wait knows
73
+ * // to keep the table. Use it when a decision has
74
+ * // a known second half, e.g. direction first,
75
+ * // then the execution contract.
51
76
  * }
52
77
  *
53
- * Options render as large cards: the sketch leads when present, with the
78
+ * Options render as large cards: the comp leads when present, with the
54
79
  * inspiration image picture-in-picture; a hero alone renders full-bleed; a
55
80
  * text-only direction gets its identity from the palette chips and tags.
56
81
  * Local image paths are served by this server; nothing is uploaded anywhere.
@@ -70,15 +95,23 @@
70
95
  * --stop --key K kill a daemonized question.
71
96
  * --update --key K --payload F deliver the next hand after a re-roll: the
72
97
  * live page swaps to loading cards when the user re-rolls, and
73
- * reloads into this new payload the moment it lands.
98
+ * reloads into this new payload the moment it lands. Always the
99
+ * same key the round started with; a second --start serves a new
100
+ * URL and strands the open tab on a hand that never arrives.
74
101
  *
75
- * node serve-question.mjs --payload question.json [--timeout 900] [--no-open] [--port 0]
102
+ * --timeout bounds the wait for a page to arrive, never the user's decision:
103
+ * once the page heartbeats, the server lives while the page does, and exits
104
+ * only after --idle-grace seconds (default 600) pass with no beat, wide
105
+ * enough to survive a closed laptop lid mid-decision.
106
+ *
107
+ * node serve-question.mjs --payload question.json [--timeout 900] [--idle-grace 600] [--no-open] [--port 0]
76
108
  */
77
109
  import http from 'node:http';
78
110
  import fs from 'node:fs';
79
111
  import path from 'node:path';
80
112
  import { spawn } from 'node:child_process';
81
113
  import { fileURLToPath } from 'node:url';
114
+ import { openSystemBrowser } from './lib/open-system-browser.mjs';
82
115
 
83
116
  function arg(name, fallback = null) {
84
117
  const i = process.argv.indexOf(`--${name}`);
@@ -94,11 +127,13 @@ if (process.env.IMPECCABLE_QUESTION_DISABLED) {
94
127
  }
95
128
  // Headless self-detection, applied only where a browser is actually wanted.
96
129
  // --no-open means the caller opens the URL itself, and --wait / --stop /
97
- // --schema never open anything: --wait polls a daemon whose browser question
98
- // was already settled at --start, --stop kills one, --schema prints text. A
99
- // spurious exit 2 from those breaks the documented loop, which polls --wait
100
- // while it exits 3 and reads --schema before building a payload.
101
- const wantsBrowser = !hasFlag('no-open') && !hasFlag('wait') && !hasFlag('stop') && !hasFlag('schema');
130
+ // --schema / --update never open anything: --wait polls a daemon whose
131
+ // browser question was already settled at --start, --stop kills one,
132
+ // --schema prints text, and --update hands the next round to a page that is
133
+ // already open. A spurious exit 2 from those breaks the documented loop,
134
+ // which polls --wait while it exits 3, reads --schema before building a
135
+ // payload, and delivers re-rolled hands with --update.
136
+ const wantsBrowser = !hasFlag('no-open') && !hasFlag('wait') && !hasFlag('stop') && !hasFlag('schema') && !hasFlag('update');
102
137
  if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) {
103
138
  const headless =
104
139
  process.env.CI ||
@@ -122,36 +157,75 @@ function printAnswer(raw) {
122
157
  if (a.hero || a.board) {
123
158
  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.");
124
159
  }
125
- if (a.sketch) {
126
- console.log('CHOSEN SKETCH: the decision sketch at that path may seed one comp probe; the comp round still renders its full set, because a sketch chose the direction, not the composition.');
160
+ if (a.comp) {
161
+ 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.');
127
162
  }
128
163
  if (a.optionId === 'canon') {
129
164
  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.');
130
165
  }
166
+ if (a.optionId === 'reroll' && a.register) {
167
+ 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.`);
168
+ }
169
+ if (a.followup && a.optionId !== 'reroll') {
170
+ 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.');
171
+ }
172
+ if (a.buildPath === 'comp' || a.buildPath === 'code') {
173
+ // The page never writes the flip itself, but "never write it" overstated
174
+ // that into a rule the agent then applied to new-work's one-time offer,
175
+ // which exists for exactly this case: a flip on a project that had no
176
+ // recorded default is the only moment the preference is ever asked for.
177
+ const origin = a.buildPathFlipped
178
+ ? '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'
179
+ : 'the round’s recorded default';
180
+ console.log(`BUILD PATH: ${a.buildPath} (${origin}). ${a.buildPath === 'comp'
181
+ ? '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.'
182
+ : '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.'}`);
183
+ }
131
184
  } catch { /* raw answer */ }
132
185
  }
133
186
 
134
187
  const payloadPath = arg('payload');
135
- const timeoutSec = Number(arg('timeout', '900'));
188
+ // --timeout bounds only the wait for a page to open; 0 is the explicit
189
+ // wait-forever. A negative or unparseable value takes the default, so a
190
+ // typo cannot disarm the no-page exit and leak the daemon.
191
+ const timeoutArg = Number(arg('timeout', '900'));
192
+ const timeoutSec = Number.isFinite(timeoutArg) && timeoutArg >= 0 ? timeoutArg : 900;
193
+ // How long the server (and the page's own delivery deadline) outlive the
194
+ // last heartbeat; a zero, negative, or unparseable value takes the default.
195
+ const idleGraceArg = Number(arg('idle-grace', '600'));
196
+ const idleGraceMs = (Number.isFinite(idleGraceArg) && idleGraceArg > 0 ? idleGraceArg : 600) * 1000;
197
+ // How long a delivered next hand may sit unclaimed before it means no page
198
+ // is coming back: --wait reads it to keep a stalled page from counting as
199
+ // closed mid-delivery, and the daemon reads it to survive until the page's
200
+ // watch claims a hand delivered moments before the idle deadline.
201
+ const NEXT_CLAIM_GRACE_MS = 10000;
136
202
  const portArg = Number(arg('port', '0'));
137
203
  const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions');
138
204
  const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`);
139
205
  const answerFile = (key) => path.join(QUESTION_DIR, `${key}.answer.json`);
206
+ // A code-to-comp flip mid-round: the page records it here and --wait
207
+ // surfaces it as its own event, because the agent must start generating
208
+ // comps while the round is still open. Comp-to-code needs no event; it is
209
+ // free and rides the final ANSWER.
210
+ const flipFile = (key) => path.join(QUESTION_DIR, `${key}.flip.json`);
140
211
 
141
212
  if (hasFlag('schema')) {
142
213
  console.log(JSON.stringify({
143
214
  title: 'Choose the visual world',
144
215
  question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
145
216
  options: [
146
- { 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/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/fillmore-handbill.webp' },
147
- { 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' },
217
+ { 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' },
218
+ { 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' },
219
+ { 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' },
220
+ { 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' },
148
221
  ],
149
- reroll: true,
222
+ reroll: { registers: ['safer', 'bolder'] },
223
+ buildPath: { value: 'comp', toggle: true },
150
224
  canon: true,
151
- 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' },
225
+ 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' },
152
226
  steer: true,
153
227
  }, null, 2));
154
- 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. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
228
+ 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.');
155
229
  process.exit(0);
156
230
  }
157
231
 
@@ -178,13 +252,32 @@ if (hasFlag('wait')) {
178
252
  let sawClose = false;
179
253
  while (Date.now() < deadline) {
180
254
  if (answered()) break;
255
+ // A build-path flip is its own event, not an answer: the round stays
256
+ // open, and the agent's job right now is comps, not code.
257
+ if (fs.existsSync(flipFile(key))) {
258
+ try { fs.rmSync(flipFile(key)); } catch { /* consumed elsewhere */ }
259
+ 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.');
260
+ process.exit(0);
261
+ }
181
262
  if (!alive()) {
182
263
  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.');
183
264
  process.exit(2);
184
265
  }
185
266
  try {
186
267
  const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8'));
187
- if (state.lastBeat && Date.now() - state.lastBeat > 15000) { sawClose = true; break; }
268
+ // A silent page is not a closed one while a freshly delivered next
269
+ // hand sits unclaimed: a stalled page stops beating by design and its
270
+ // watch reloads, beating again, within seconds of the file landing.
271
+ // The suppression is age-bound because a closed tab never claims the
272
+ // hand: a file still there after the grace means no page is coming.
273
+ const midDelivery = (() => {
274
+ try { if (Date.now() - fs.statSync(path.join(QUESTION_DIR, `${key}.next.json`)).mtimeMs < NEXT_CLAIM_GRACE_MS) return true; }
275
+ catch { /* nothing delivered */ }
276
+ // The claim deletes that file before the reloaded page can beat: the
277
+ // claim stamp the server persisted covers the same bounded gap.
278
+ return Boolean(state.claimedAt) && Date.now() - state.claimedAt < NEXT_CLAIM_GRACE_MS;
279
+ })();
280
+ if (!midDelivery && state.lastBeat && Date.now() - state.lastBeat > 15000) { sawClose = true; break; }
188
281
  } catch { /* state mid-write */ }
189
282
  await new Promise((r) => setTimeout(r, 1000));
190
283
  }
@@ -195,12 +288,16 @@ if (hasFlag('wait')) {
195
288
  if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
196
289
  const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
197
290
  printAnswer(collected);
198
- // A re-roll keeps the table open: the server stays alive awaiting --update,
199
- // so only the answer file is consumed. Terminal choices clean up fully.
200
- let isRerollAnswer = false;
201
- try { isRerollAnswer = JSON.parse(collected).optionId === 'reroll'; } catch { /* treat as terminal */ }
291
+ // A re-roll or a followup-round pick keeps the table open: the server stays
292
+ // alive awaiting --update, so only the answer file is consumed. Terminal
293
+ // choices clean up fully.
294
+ let keepsTableOpen = false;
295
+ try {
296
+ const parsedAnswer = JSON.parse(collected);
297
+ keepsTableOpen = parsedAnswer.optionId === 'reroll' || parsedAnswer.followup === true;
298
+ } catch { /* treat as terminal */ }
202
299
  try { fs.rmSync(answerFile(key)); } catch { /* already gone */ }
203
- if (!isRerollAnswer) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
300
+ if (!keepsTableOpen) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
204
301
  process.exit(0);
205
302
  }
206
303
 
@@ -217,10 +314,33 @@ if (hasFlag('stop')) {
217
314
  if (hasFlag('update')) {
218
315
  const key = arg('key');
219
316
  if (!key || !payloadPath) { console.error('serve-question: --update needs --key and --payload'); process.exit(1); }
220
- JSON.parse(fs.readFileSync(payloadPath, 'utf8'));
221
- try { process.kill(JSON.parse(fs.readFileSync(stateFile(key), 'utf8')).pid, 0); }
222
- catch { console.error('serve-question: no live question server for that key'); process.exit(2); }
223
- fs.copyFileSync(payloadPath, path.join(QUESTION_DIR, `${key}.next.json`));
317
+ // A hand the server cannot load must fail here, at the sender: delivered
318
+ // anyway, the page would see ready:true for a round that never renders.
319
+ const nextRound = JSON.parse(fs.readFileSync(payloadPath, 'utf8'));
320
+ if (!nextRound || !Array.isArray(nextRound.options) || nextRound.options.length === 0) {
321
+ console.error('serve-question: --update payload needs an options array; nothing was delivered. Fix the payload and rerun --update on the same key.');
322
+ process.exit(1);
323
+ }
324
+ // Liveness mirrors --wait: a fresh page heartbeat is the primary proof, the
325
+ // kill probe is secondary, and EPERM means a sandbox blocked the signal,
326
+ // never a dead server. This is the documented re-roll delivery step, so a
327
+ // false "no live server" here strands the page mid-shuffle.
328
+ const live = (() => {
329
+ try {
330
+ const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8'));
331
+ if (state.lastBeat && Date.now() - state.lastBeat < 12000) return true;
332
+ try { process.kill(state.pid, 0); return true; }
333
+ catch (err) { return err.code === 'EPERM'; }
334
+ } catch { return false; }
335
+ })();
336
+ if (!live) { console.error('serve-question: no live question server for that key; the page it served is gone too. Re-present the round with --start and a fresh key, or fall back to the structured question tool.'); process.exit(2); }
337
+ const deliveredFile = path.join(QUESTION_DIR, `${key}.next.json`);
338
+ fs.copyFileSync(payloadPath, deliveredFile);
339
+ // The file's mtime is the delivery clock --wait's grace reads: stamp it
340
+ // here, because a copy that preserves the source payload's older mtime
341
+ // would start the grace already spent.
342
+ const deliveredAt = new Date();
343
+ fs.utimesSync(deliveredFile, deliveredAt, deliveredAt);
224
344
  console.log('next round delivered; the page reloads itself');
225
345
  process.exit(0);
226
346
  }
@@ -238,7 +358,8 @@ if (hasFlag('start')) {
238
358
  const logFd = fs.openSync(logFile, 'a');
239
359
  const child = spawn(process.execPath, [
240
360
  fileURLToPath(import.meta.url), '--payload', payloadPath, '--detached-serve', '--key', key,
241
- '--timeout', String(timeoutSec), ...(hasFlag('open') ? [] : ['--no-open']),
361
+ '--timeout', String(timeoutSec), ...(arg('idle-grace') ? ['--idle-grace', arg('idle-grace')] : []),
362
+ ...(hasFlag('open') ? [] : ['--no-open']),
242
363
  ], { detached: true, stdio: ['ignore', logFd, logFd] });
243
364
  child.unref();
244
365
  fs.closeSync(logFd);
@@ -269,6 +390,19 @@ else raw = fs.readFileSync(0, 'utf8');
269
390
  let payload;
270
391
  let options;
271
392
  let localImages = [];
393
+ // Build path (comp-led vs code-led): the payload carries the recorded
394
+ // default; the page's toggle updates the live value per session. The server
395
+ // owns both so the final ANSWER states the path and whether it was flipped
396
+ // even when the round never rendered a toggle.
397
+ let buildPathDefault = null;
398
+ let liveBuildPath = null;
399
+ // True between a collected re-roll or followup answer and the --update that
400
+ // replaces the round: the window where GET / must serve the wait, not the
401
+ // answered cards. The timestamp anchors the delivery deadline server-side,
402
+ // so a native refresh re-enters the wait with the time already spent, never
403
+ // with a fresh allowance.
404
+ let awaitingNext = false;
405
+ let awaitingNextSince = 0;
272
406
 
273
407
  function loadRound(json) {
274
408
  const parsed = JSON.parse(json);
@@ -284,10 +418,10 @@ function loadRound(json) {
284
418
  localImages.push(abs);
285
419
  return `/img/${localImages.length - 1}`;
286
420
  };
287
- // Sketches stream in after the page is served, so their slots register
421
+ // Comps stream in after the page is served, so their slots register
288
422
  // whether or not the file exists yet; /img answers 404 until it lands and
289
- // the page polls the slot. Remote sketch URLs pass through untouched.
290
- const sketchSrc = (value) => {
423
+ // the page polls the slot. Remote comp URLs pass through untouched.
424
+ const compSrc = (value) => {
291
425
  if (!value) return null;
292
426
  if (/^https?:\/\//.test(value)) return value;
293
427
  localImages.push(path.resolve(value));
@@ -298,14 +432,29 @@ function loadRound(json) {
298
432
  ...option,
299
433
  heroSrc: imageSrc(option.hero),
300
434
  boardSrc: imageSrc(option.board),
301
- sketchSrc: sketchSrc(option.sketch),
435
+ compSrc: compSrc(option.comp ?? option.sketch),
302
436
  });
303
437
  options = parsed.options.map(decorate);
438
+ // The verdict routes rendering: full cards first, then the canon, then the
439
+ // declined cards dead last in their own payload order. The reorder happens
440
+ // here so a payload that interleaves them still renders the weighing's
441
+ // shape, and the deck reads as a gradient of standing: contenders, the
442
+ // familiar door, then the demoted row.
443
+ const declined = options.filter((o) => o.verdict === 'declined');
444
+ options = options.filter((o) => o.verdict !== 'declined');
304
445
  // The standing exit as a full card: same anatomy, reserved id, rendered
305
446
  // subordinate by the page. Without it, canon stays the quiet footer action.
306
447
  if (parsed.canonCard && typeof parsed.canonCard === 'object') {
307
448
  options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
308
449
  }
450
+ options = [...options, ...declined];
451
+ buildPathDefault = (parsed.buildPath && (parsed.buildPath.value === 'comp' || parsed.buildPath.value === 'code'))
452
+ ? { value: parsed.buildPath.value, toggle: parsed.buildPath.toggle === true }
453
+ : null;
454
+ liveBuildPath = buildPathDefault?.value ?? null;
455
+ // Last: a round that failed to load anywhere above must leave the waiting
456
+ // window open, never resurrect the answered cards.
457
+ awaitingNext = false;
309
458
  }
310
459
  try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
311
460
  const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
@@ -313,7 +462,11 @@ const nextFile = () => detachedKey ? path.join(QUESTION_DIR, `${detachedKey}.nex
313
462
 
314
463
  const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
315
464
 
316
- function page() {
465
+ function page(waiting = false) {
466
+ // The delivery deadline survives refreshes: a waiting page gets whatever
467
+ // remains of the original allowance, so reloading cannot renew it. Spent
468
+ // means the page renders already stalled and never starts a heartbeat.
469
+ const waitBudgetMs = waiting ? Math.max(0, awaitingNextSince + idleGraceMs - Date.now()) : idleGraceMs;
317
470
  const flipChip = (label) => `<button type="button" class="chip flip" aria-label="Flip the card"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a8 8 0 1 1-8 8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M4 5.5V12h6.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg><span>${label}</span></button>`;
318
471
  const expandChip = `<button type="button" class="chip expand" aria-label="Expand the image"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 15v5h-5M20 9V4h-5M4 15v5h5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></button>`;
319
472
  // Structured anatomy: chips and one-line facts render when the payload
@@ -321,7 +474,30 @@ function page() {
321
474
  // and material tags give a text-only direction an immediate identity that
322
475
  // no generation luck can distort.
323
476
  const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
324
- const hasBack = (option) => Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
477
+ const demoted = (option) => option.verdict === 'declined';
478
+ // The build path (comp-led vs code-led) is a workflow preference, not a
479
+ // design decision: the payload carries the recorded default and whether
480
+ // the page offers the toggle. On a code-led round a declared comp path is
481
+ // a flip reserve, not a face: wireframes render, and the slot only starts
482
+ // shimmering when the user flips to comp.
483
+ const buildPath = buildPathDefault;
484
+ const codeLed = buildPath?.value === 'code';
485
+ // Salience parity: a card's imagery weight is capped by the assigned card's.
486
+ // When the lead card has no media at all (no image generation this round,
487
+ // and no catalog art of its own), full-bleed catalog art beside a text-only
488
+ // assigned card would let rendering luck outvote the weighing: users click
489
+ // the colorful thing. Declined cards are thumb-only regardless; the verdict
490
+ // demoted them, and a full-bleed hero would promote them right back.
491
+ const identityRound = !(options[0] && (options[0].compSrc || options[0].heroSrc || options[0].boardSrc));
492
+ // A declined card never renders a full media face, comp included: even a
493
+ // declared comp would buy back the salience the verdict took away.
494
+ const faceComp = (option) => (demoted(option) || codeLed) ? null : option.compSrc;
495
+ const thumbOnly = (option) => !faceComp(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
496
+ const hasMedia = (option) => Boolean(faceComp(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
497
+ // The back exists to keep long facts off a card whose front is an image;
498
+ // a card with no art has no flip chip to reach it, so it gets no back and
499
+ // the full read lives on the front instead.
500
+ const hasBack = (option) => hasMedia(option) && Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
325
501
  const anatomy = (option) => {
326
502
  const rows = [];
327
503
  if (option.thesis) rows.push(`<p class="thesis">${esc(option.thesis)}</p>`);
@@ -333,10 +509,44 @@ function page() {
333
509
  idBits.push(option.materials.slice(0, 4).map((m) => `<span class="tag">${esc(m)}</span>`).join(''));
334
510
  }
335
511
  if (idBits.length) rows.push(`<div class="identity">${idBits.join('')}</div>`);
512
+ // Donations from declined challengers render as named raise lines: the
513
+ // assigned card arrives already raised by the hand it beat, and the raise
514
+ // is readable, because a raise nobody can read did not happen. One raise
515
+ // renders inline; several become a compact cycler (click advances), so a
516
+ // generous hand cannot blow the card out of proportion.
517
+ if (Array.isArray(option.raised) && option.raised.length) {
518
+ const nameOf = (id) => options.find((o) => o.id === id)?.label || String(id ?? '');
519
+ 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>`);
520
+ 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>`;
521
+ if (raiseLines.length > 1) {
522
+ 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">
523
+ ${raisesHead(raiseLines.length)}
524
+ ${raiseLines.join('')}
525
+ <span class="sr-live" aria-live="polite"></span>
526
+ </div>`);
527
+ } else {
528
+ rows.push(`<div class="raises">${raisesHead(1)}${raiseLines[0]}</div>`);
529
+ }
530
+ }
531
+ // Demoted art stays reachable as a labeled thumb: the catalog world
532
+ // explains where the direction comes from without buying it back the
533
+ // salience the verdict took away.
534
+ if (thumbOnly(option)) {
535
+ 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>`);
536
+ }
336
537
  // The front carries only what the choice needs: thesis, identity, and the
337
538
  // honest risk clamped to two lines. First viewport and the case read on
338
- // the card's back; once the sketch lands, the first viewport is a picture.
339
- rows.push(fact('Risk', option.risk, 'clamp'));
539
+ // the card's back; once the comp lands, the first viewport is a picture.
540
+ // With no art there is no back, so the full read fills the room the
541
+ // image would have taken.
542
+ if (hasMedia(option)) {
543
+ rows.push(fact('Risk', option.risk, 'clamp'));
544
+ } else {
545
+ rows.push(fact('First viewport', option.viewport));
546
+ rows.push(fact('The case', option.case));
547
+ rows.push(fact('Kept', option.kept));
548
+ rows.push(fact('Risk', option.risk));
549
+ }
340
550
  if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
341
551
  else if (option.body && option.thesis && !hasBack(option)) rows.push(`<p class="detail more">${esc(option.body)}</p>`);
342
552
  return rows.join('\n ');
@@ -344,42 +554,76 @@ function page() {
344
554
  const backFacts = (option) => [
345
555
  fact('First viewport', option.viewport),
346
556
  fact('The case', option.case),
557
+ fact('Kept', option.kept),
347
558
  fact('Risk', option.risk),
348
559
  option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
349
560
  ].filter(Boolean).join('\n ');
350
561
  const media = (option) => {
351
- const inspiration = option.heroSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
352
- <img src="${esc(option.heroSrc)}" alt="">
562
+ const inspirationSrc = option.heroSrc || option.boardSrc;
563
+ const inspiration = inspirationSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
564
+ <img src="${esc(inspirationSrc)}" alt="">
353
565
  <figcaption>inspiration</figcaption>
354
566
  </figure>` : '';
355
567
  const details = hasBack(option) ? flipChip('Details') : '';
356
- if (option.sketchSrc) {
357
- return `<div class="media sketching" data-sketch="${esc(option.sketchSrc)}">
358
- <div class="shimmer"><span class="sketch-note">sketching&hellip;</span></div>
359
- <img class="sketch" alt="" hidden>
568
+ // Thumb-only art renders inside the body via anatomy(), never as a face,
569
+ // and a declined card's comp slot is ignored outright.
570
+ if (thumbOnly(option)) return '';
571
+ if (faceComp(option)) {
572
+ const textOnlyFacts = backFacts(option);
573
+ return `<div class="media comp-pending" data-comp="${esc(option.compSrc)}">
574
+ <div class="shimmer"><span class="comp-note">rendering&hellip;</span></div>
575
+ <img class="comp" alt="" hidden>
360
576
  ${inspiration}
577
+ <template class="text-only-facts">${textOnlyFacts}</template>
361
578
  <div class="chips">${expandChip}${details}</div>
362
579
  </div>`;
363
580
  }
364
581
  if (option.heroSrc || option.boardSrc) {
365
- return `<div class="media">
582
+ // Without a comp the catalog art is the card's face; it stays a
583
+ // labeled reference so it never reads as the promise of the build.
584
+ return `<div class="media" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
366
585
  <img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
586
+ <p class="media-label">inspiration</p>
367
587
  <div class="chips">${expandChip}${details}</div>
368
588
  </div>`;
369
589
  }
370
590
  return '';
371
591
  };
592
+ // Wireframe media: a code-led card's layout schematic, authored as grid
593
+ // regions in the payload and drawn by the page; boxes and labels, no art.
594
+ // It fills the media slot only when the card has no imagery, and it never
595
+ // counts toward salience or earns a card back: the full read stays on the
596
+ // front, exactly like a text-only card.
597
+ const wire = (option) => {
598
+ const frame = option.wireframe;
599
+ if (!frame || !Array.isArray(frame.regions) || !frame.regions.length || media(option) || demoted(option)) return '';
600
+ const cols = Number(frame.cols) > 0 ? Number(frame.cols) : 12;
601
+ const rows = Number(frame.rows) > 0 ? Number(frame.rows) : 10;
602
+ const pct = (n, total) => `${Math.max(0, Math.min(100, (n / total) * 100)).toFixed(2)}%`;
603
+ const cells = frame.regions.slice(0, 12).map((region) => {
604
+ const x = Number(region.x) || 0;
605
+ const y = Number(region.y) || 0;
606
+ const w = Math.max(Number(region.w) || 1, 0.5);
607
+ const h = Math.max(Number(region.h) || 1, 0.5);
608
+ 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>`;
609
+ }).join('');
610
+ return `<div class="media wire" role="img" aria-label="Layout schematic">
611
+ <div class="wire-field">${cells}</div>
612
+ <p class="media-label">layout</p>
613
+ </div>`;
614
+ };
615
+ const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this';
372
616
  const cards = options.map((option, index) => `
373
- <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)}">
617
+ <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)}"` : ''}>
374
618
  <div class="card-inner">
375
- <div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
376
- ${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
377
- ${media(option)}
619
+ <div class="face front${index === 0 ? ' lead' : ''}${(media(option) || wire(option)) ? '' : ' text-only'}">
620
+ ${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>' : ''}
621
+ ${media(option) || wire(option)}
378
622
  <div class="body">
379
623
  ${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
380
624
  <h2>${esc(option.label)}</h2>
381
625
  ${anatomy(option)}
382
- <button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
626
+ <button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
383
627
  </div>
384
628
  </div>
385
629
  ${hasBack(option) ? `<div class="face back${index === 0 ? ' lead' : ''}">
@@ -390,7 +634,7 @@ function page() {
390
634
  <div class="body back-body">
391
635
  ${option.boardSrc ? `<p class="tier">The full read &middot; ${esc(option.label)}</p>` : ''}
392
636
  ${backFacts(option)}
393
- <button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
637
+ <button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
394
638
  </div>
395
639
  </div>` : ''}
396
640
  </div>
@@ -423,9 +667,12 @@ function page() {
423
667
  --ks-font-display: "Alumni Sans", "Albert Sans", Arial, sans-serif;
424
668
  --ks-font: "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, system-ui, sans-serif;
425
669
  --ks-mono: "SFMono-Regular", "Roboto Mono", "JetBrains Mono", Consolas, monospace;
670
+ /* One inset shared by the content column, the deck's snap padding, and
671
+ the sticky footer, so all three align on the same 90rem column. */
672
+ --page-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2));
426
673
  }
427
674
  * { box-sizing: border-box; margin: 0; }
428
- 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; }
675
+ 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; }
429
676
  #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; }
430
677
  #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; }
431
678
  header, main, footer { position: relative; z-index: 1; }
@@ -437,7 +684,7 @@ function page() {
437
684
  .brand { display: flex; align-items: center; gap: .55rem; color: var(--ks-kinpaku); }
438
685
  .brand svg { width: 22px; height: 22px; }
439
686
  .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); }
440
- .headline { display: flex; align-items: center; gap: .9rem; }
687
+ .headline { display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; }
441
688
  .headline-die { flex: none; width: 34px; height: 34px; color: var(--ks-kinpaku); }
442
689
  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); }
443
690
  .question { color: var(--ks-text-muted); margin-top: .7rem; max-width: 52rem; }
@@ -449,12 +696,24 @@ function page() {
449
696
  .deck-shell { position: relative; width: 100vw; margin-left: calc(50% - 50vw); }
450
697
  /* One row in a wide viewport, one column in a tall one; the deck scrolls on
451
698
  its axis with snap points and the arrows page it card by card. */
452
- .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; }
699
+ .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; }
453
700
  .grid::-webkit-scrollbar { display: none; }
454
- /* Wide enough that the sketch carries the card: at 27vw the imagery read
701
+ /* Wide enough that the comp carries the card: at 27vw the imagery read
455
702
  as a thumbnail above a column of copy, and the copy won the attention
456
- contest the sketch is supposed to win. */
703
+ contest the comp is supposed to win. */
457
704
  .grid > .card { flex: 0 0 clamp(24rem, 34vw, 34rem); scroll-snap-align: center; }
705
+ /* Short landscape viewports (13-inch laptops): header, a 34vw card, and the
706
+ footer do not fit 800px of height, so the headline compacts and the deck
707
+ narrows. Height is the axis that gives; the sticky footer keeps the
708
+ round's verbs on screen while a too-tall card scrolls. */
709
+ @media (min-aspect-ratio: 1/1) and (max-height: 900px) {
710
+ body { padding-top: 1.1rem; }
711
+ h1 { font-size: clamp(2rem, 3.4vw, 2.9rem); }
712
+ .question { margin-top: .45rem; }
713
+ .stage { gap: 1rem; }
714
+ .grid > .card { flex-basis: clamp(20rem, 27vw, 27rem); }
715
+ .grid > .card.declined { flex-basis: clamp(13rem, 18vw, 18rem); }
716
+ }
458
717
  .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; }
459
718
  .nav:hover { border-color: var(--ks-kinpaku-deep); color: var(--ks-kinpaku-pale); }
460
719
  .nav[disabled] { opacity: .25; cursor: default; }
@@ -481,6 +740,14 @@ function page() {
481
740
  .nav.next { right: auto; left: 50%; top: auto; bottom: 6px; transform: translate(-50%, 0); }
482
741
  .fade-prev { top: 0; left: 0; right: 0; bottom: auto; width: auto; height: 72px; background: linear-gradient(180deg, var(--ks-lacquer), transparent); }
483
742
  .fade-next { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: 72px; background: linear-gradient(0deg, var(--ks-lacquer), transparent); }
743
+ /* In the vertical deck the cross axis is horizontal: flex-start would
744
+ shrink a declined card to content WIDTH, not height, so it stretches
745
+ like every other card and its height is already its own. */
746
+ .grid > .card.declined { align-self: stretch; }
747
+ /* The sticky bar is a wide-viewport fix. Here it would sit over the
748
+ deck's More pager and cost a third of a phone screen, and the deck
749
+ already scrolls internally, so the footer stays in the page flow. */
750
+ footer { position: static; width: auto; margin: 1rem 0 0; padding: .7rem 0 1.2rem; background: transparent; border-top: 0; backdrop-filter: none; }
484
751
  }
485
752
  .card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
486
753
  .card:hover { transform: rotate(0deg) translateY(-4px); }
@@ -504,7 +771,7 @@ function page() {
504
771
  region entirely instead of reserving a blank 16:9 void. */
505
772
  .face.text-only .kicker { position: static; align-self: flex-start; margin: 14px 0 0 14px; }
506
773
  .face.text-only .body { padding-top: 12px; }
507
- /* 16/10 matches the landscape sketch frame; portrait art overrides the
774
+ /* 16/10 matches the landscape comp frame; portrait art overrides the
508
775
  slot with its own exact ratio at load (see the load listener), and the
509
776
  deck narrows so portrait cards line up side by side. */
510
777
  .media { position: relative; width: 100%; aspect-ratio: 16/10; flex: none; }
@@ -540,24 +807,82 @@ function page() {
540
807
  .body.back-body { overflow-y: auto; flex: 1; scrollbar-width: thin; }
541
808
  /* Inspiration rides picture-in-picture: the catalog world explains where the
542
809
  direction comes from without promising what the build will look like. */
543
- /* Hovering the inspiration takes over the whole media region; the sketch is
810
+ /* Hovering the inspiration takes over the whole media region; the comp is
544
811
  the promise, the inspiration is a glance, so the glance must cost nothing. */
545
812
  .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); }
546
813
  .pip img { display: block; width: 100%; height: 100%; object-fit: cover; }
547
814
  .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); }
548
815
  .pip:hover { left: 0; bottom: 0; width: 100%; height: 100%; border-radius: 0; z-index: 3; }
549
- .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); }
816
+ .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); }
817
+ /* Catalog art standing in for a comp-less card is a reference, and says so
818
+ on its face; the same pill later carries "artwork unavailable". */
819
+ .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); }
820
+ /* Art that never arrives collapses to the card's own palette (painted
821
+ inline from its swatches) instead of sitting as a dark void wearing a
822
+ zoom cursor; the scrim keeps the label legible over saturated fields,
823
+ passes clicks through, and the flip chips stay above it. A card with no
824
+ palette falls back to the quiet graphite field. */
825
+ .media.unavailable { background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
826
+ .media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
827
+ .media.unavailable .chips { z-index: 2; }
550
828
  /* A stand-in is honest about being one: dimmed, labeled, and replaced by
551
- the real sketch whenever it lands. */
552
- .media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
829
+ the real comp whenever it lands. */
830
+ .media.stand-in img.comp { filter: brightness(.72) saturate(.85); }
553
831
  .media.stand-in .pip { display: none; }
554
832
  .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); }
555
- .media.sketching { position: relative; }
556
- .media.sketching .shimmer { position: absolute; inset: 0; }
557
- .media img.sketch { position: relative; z-index: 1; }
833
+ .media.comp-pending { position: relative; }
834
+ .media.comp-pending .shimmer { position: absolute; inset: 0; }
835
+ .media img.comp { position: relative; z-index: 1; }
558
836
  /* The generic .media img display:block would defeat [hidden] and float an
559
- empty block over the shimmer; an unloaded sketch must truly not render. */
837
+ empty block over the shimmer; an unloaded comp must truly not render. */
560
838
  .media img[hidden] { display: none; }
839
+ /* Declined challengers: the weighing demoted them, so the card is narrower
840
+ and quieter, its catalog art rides as a labeled thumb in the body, and
841
+ the action reads "Adopt anyway". Adoptable, never deleted: the demoted
842
+ row is the hand's proof of judgment. */
843
+ /* Narrow AND short: without align-self the stretch default drags a thin
844
+ declined card to the tallest contender's height, a strange stilt of a
845
+ card beside the full hand. */
846
+ .grid > .card.declined { flex: 0 0 clamp(15rem, 21vw, 21rem); align-self: flex-start; }
847
+ .card.declined .face { background: var(--ks-graphite); }
848
+ .card.declined:hover .face { border-color: var(--ks-text-faint); }
849
+ .card.declined h2 { font-size: 1rem; color: var(--ks-text); }
850
+ .kicker.declined-k { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); }
851
+ .card.declined button.choose { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font-size: .85rem; padding: 8px 22px; }
852
+ .card.declined button.choose:hover { background: var(--ks-graphite-2); border-color: var(--ks-text-muted); }
853
+ /* Wireframe media: the code-led schematic. Quiet boxes in the card's own
854
+ chrome; uniform salience across cards by construction, so it needs no
855
+ parity rules. */
856
+ .media.wire { background: var(--ks-lacquer); border-bottom: 1px solid var(--ks-rule); }
857
+ .wire-field { position: absolute; inset: 12px 12px 26px; }
858
+ .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; }
859
+ .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; }
860
+ .wire-region.accent { border-color: oklch(84% 0.19 80.46 / 0.5); background: oklch(84% 0.19 80.46 / 0.06); }
861
+ .wire-region.accent span { color: var(--ks-kinpaku-rich); }
862
+ /* Thumb-scale inspiration: present, labeled, zoomable, and incapable of
863
+ outshouting a text-only assigned card. */
864
+ .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); }
865
+ .inspo img { display: block; width: 100%; height: 100%; object-fit: cover; }
866
+ .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); }
867
+ /* Raises: the improvements the dealt worlds donated to the assigned
868
+ direction, each named for its donor world. Patina, not kinpaku:
869
+ provenance, not a call to action. A quiet contained panel, never an
870
+ accent side-tab. */
871
+ .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; }
872
+ .raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; }
873
+ .raise .fact-label { color: var(--ks-patina); }
874
+ /* Several kept ideas cycle instead of stacking: one visible at a time, a
875
+ counter for the rest, the whole block advances on click. */
876
+ .raises-cycle { cursor: pointer; transition: border-color .2s ease; }
877
+ .raises-cycle:hover { border-color: oklch(70% 0.12 188 / 0.45); }
878
+ .raises-cycle .raise { display: none; }
879
+ .raises-cycle .raise.active { display: block; }
880
+ .raises-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
881
+ .raises-head .fact-label { color: var(--ks-patina); }
882
+ .raises-count { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .14em; color: var(--ks-text-faint); }
883
+ .raises-count::after { content: " \\203A"; }
884
+ .raises-cycle:hover .raises-count { color: var(--ks-patina); }
885
+ .sr-live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
561
886
  /* The standing exit as a card: present with full anatomy, never dressed as a
562
887
  contender. Graphite instead of kinpaku, and it never takes the lead ring. */
563
888
  .card.canon .face { border-color: var(--ks-rule); background: var(--ks-graphite); }
@@ -567,17 +892,53 @@ function page() {
567
892
  .card.canon button.choose:hover { border-color: var(--ks-text-muted); background: var(--ks-graphite-2); }
568
893
  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; }
569
894
  button.choose:hover { background: var(--ks-kinpaku-pale); }
570
- footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
895
+ /* The round's verbs stay reachable on short viewports: the footer is a
896
+ full-bleed bar stuck to the viewport bottom and the deck scrolls under
897
+ it. Same inset as the content column, so the controls stay aligned. */
898
+ 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); }
571
899
  #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; }
572
900
  #steer:focus { outline: none; border-color: var(--ks-patina); }
573
- #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; }
574
- #reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
575
- #reroll svg { width: 15px; height: 15px; }
901
+ /* Build-path toggle: a workflow preference surfaced as a quiet segmented
902
+ control on the headline row, right-aligned opposite the title, its trade stated in
903
+ one line that changes with the selection. The default comes from the
904
+ payload (settings); flipping binds this session only, and the agent
905
+ learns about a code-to-comp flip live. Rendered only when the payload
906
+ offers it, which the agent does only when image generation exists. */
907
+ #build-path { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; flex: none; margin-left: auto; }
908
+ .bp-switch { display: inline-flex; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; }
909
+ .bp-note { text-align: right; }
910
+ .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; }
911
+ .bp-opt + .bp-opt { border-left: 1px solid var(--ks-rule); }
912
+ .bp-opt.active { color: var(--ks-dark-ink); background: var(--ks-kinpaku-rich); }
913
+ .bp-opt:not(.active):hover { color: var(--ks-text); }
914
+ .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; }
915
+ /* Flipping to comp starts billed, minutes-long generation, so it asks
916
+ first; flipping back is free and never does. */
917
+ #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; }
918
+ #bp-confirm[hidden] { display: none; }
919
+ #bp-confirm.open { opacity: 1; }
920
+ .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); }
921
+ .bp-confirm-panel h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; color: var(--ks-champagne); margin-bottom: .55rem; }
922
+ .bp-confirm-panel p { font-size: .875rem; line-height: 1.55; color: var(--ks-text-muted); }
923
+ .bp-confirm-actions { display: flex; gap: .6rem; margin-top: 1.1rem; }
924
+ .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; }
925
+ .bp-confirm-go:hover { background: var(--ks-kinpaku-pale); }
926
+ .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; }
927
+ .bp-confirm-stay:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
928
+ .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; }
929
+ .reroll-btn:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
930
+ .reroll-btn svg { width: 15px; height: 15px; }
931
+ .reroll-btn[disabled] { opacity: .4; cursor: default; }
932
+ /* The register steers read quieter than the plain roll: they are exits from
933
+ the current register, not the round's main verbs. */
934
+ #reroll-safer, #reroll-bolder { color: var(--ks-text-muted); min-height: 38px; }
935
+ #reroll-safer:hover, #reroll-bolder:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
576
936
  /* The quiet exit: always available, never argued with, visually subordinate
577
937
  to the dealt cards and the re-roll so it reads as the user's own door,
578
938
  not a recommendation. */
579
939
  #canon { align-self: center; padding: 0 4px; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: inherit; opacity: .45; background: transparent; border: none; border-bottom: 1px dotted currentColor; cursor: pointer; transition: opacity .2s ease; }
580
940
  #canon:hover { opacity: .85; }
941
+ #canon[disabled] { opacity: .18; cursor: default; }
581
942
  .card.skeleton .media { background: var(--ks-graphite); }
582
943
  .shimmer { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
583
944
  .card.skeleton .line { height: 11px; border-radius: 4px; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
@@ -589,10 +950,23 @@ function page() {
589
950
  @keyframes shimmer { from { background-position: 120% 0; } to { background-position: -80% 0; } }
590
951
  @media (prefers-reduced-motion: reduce) { .shimmer, .card.skeleton .line { animation: none; } }
591
952
  .done { display: flex; flex-direction: column; align-items: center; gap: 1rem; padding: 7rem 1rem; font-family: var(--ks-font-display); font-size: 1.4rem; color: var(--ks-champagne); text-align: center; }
953
+ .stall { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 1.2rem; padding: 4.5rem 1rem; font-family: var(--ks-font-display); font-size: 1.4rem; color: var(--ks-champagne); text-align: center; }
954
+ .stall .choose { align-self: center; margin-top: 0; }
592
955
  </style>
593
956
  <div id="ambient" aria-hidden="true"></div>
594
957
  <div id="scrim" aria-hidden="true"></div>
595
958
  <div id="lightbox" hidden><img alt=""></div>
959
+ <template id="tpl-expand-chip">${expandChip}</template>
960
+ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria-labelledby="bp-confirm-title" hidden>
961
+ <div class="bp-confirm-panel">
962
+ <h2 id="bp-confirm-title">Flip to comp-first?</h2>
963
+ <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>
964
+ <div class="bp-confirm-actions">
965
+ <button type="button" class="bp-confirm-go" data-confirm>Render comps</button>
966
+ <button type="button" class="bp-confirm-stay" data-cancel>Keep code-first</button>
967
+ </div>
968
+ </div>
969
+ </div>` : ''}
596
970
  <header>
597
971
  <div class="brand">
598
972
  <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>
@@ -604,6 +978,13 @@ function page() {
604
978
  <div class="headline">
605
979
  <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>
606
980
  <h1>${esc(payload.title || 'Choose a direction')}</h1>
981
+ ${buildPath?.toggle ? `<div id="build-path" data-default="${buildPath.value}">
982
+ <div class="bp-switch" role="radiogroup" aria-label="Build path">
983
+ <button type="button" class="bp-opt" data-bp="comp" role="radio" aria-checked="false">Comp first</button>
984
+ <button type="button" class="bp-opt" data-bp="code" role="radio" aria-checked="false">Code first</button>
985
+ </div>
986
+ <p class="bp-note" data-bp-note></p>
987
+ </div>` : ''}
607
988
  </div>
608
989
  ${payload.question ? `<p class="question">${esc(payload.question)}</p>` : ''}
609
990
  <div class="deck-shell">
@@ -617,16 +998,46 @@ function page() {
617
998
  </main>
618
999
  <footer>
619
1000
  ${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
620
- ${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>' : ''}
1001
+ ${(() => {
1002
+ if (!payload.reroll) return '';
1003
+ 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>';
1004
+ const registers = Array.isArray(payload.reroll.registers) ? payload.reroll.registers.filter((r) => r === 'safer' || r === 'bolder') : [];
1005
+ // The registers are the user's steering wheel on the familiar-to-bold
1006
+ // axis; the plain re-roll sits between them so the spatial order matches
1007
+ // the axis it names.
1008
+ 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>' : '';
1009
+ 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>' : '';
1010
+ return `${safer}<button class="reroll-btn" id="reroll">${die}<span>Re-roll</span></button>${bolder}`;
1011
+ })()}
621
1012
  ${payload.canon && !payload.canonCard ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
622
1013
  </footer>
623
1014
  <script>
624
1015
  const steer = () => document.getElementById('steer')?.value || '';
625
- const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
626
- beat();
627
- setInterval(beat, 5000);
1016
+ // A followup round's pick keeps the tab: the next round arrives via
1017
+ // --update, so the page shows the loading hand instead of goodbye. Detached
1018
+ // mode only, and the page must agree with the server: a blocking server
1019
+ // exits on any pick and has no update channel, so a followup payload there
1020
+ // still gets the goodbye screen, never a loading hand nothing will resolve.
1021
+ const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
1022
+ const KEY = ${JSON.stringify(detachedKey || '')};
1023
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
1024
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
1025
+ ${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
1026
+ const beatTimer = setInterval(beat, 5000);
1027
+ // A dead server must fail loudly: awaiting a rejected fetch here used to
1028
+ // swallow the click and never print the confirmation, so the user believed
1029
+ // a choice had landed that no one would ever collect.
628
1030
  async function answer(optionId) {
629
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
1031
+ // Quiet at the click: a re-roll or canon posted while this pick's POST
1032
+ // is in flight would overwrite the answer being collected.
1033
+ document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
1034
+ try {
1035
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
1036
+ } catch {
1037
+ document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
1038
+ return;
1039
+ }
1040
+ if (FOLLOWUP) { await awaitNextRound(true); return; }
630
1041
  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>';
631
1042
  }
632
1043
  document.querySelectorAll('button.choose').forEach(b => b.addEventListener('click', () => answer(b.dataset.id)));
@@ -635,6 +1046,25 @@ function page() {
635
1046
  b.closest('.card').classList.toggle('flipped');
636
1047
  }));
637
1048
 
1049
+ // Raise cycler: click (or Enter) advances to the next donation.
1050
+ document.querySelectorAll('.raises-cycle').forEach(cycle => {
1051
+ const raises = [...cycle.querySelectorAll('.raise')];
1052
+ const count = cycle.querySelector('[data-raises-count]');
1053
+ let at = 0;
1054
+ const live = cycle.querySelector('.sr-live');
1055
+ const show = (announce) => {
1056
+ raises.forEach((raise, i) => raise.classList.toggle('active', i === at));
1057
+ if (count) count.textContent = (at + 1) + '/' + raises.length;
1058
+ // Screen readers hear the raise they just advanced to; the initial
1059
+ // render stays quiet so page load does not narrate every card.
1060
+ if (announce && live) live.textContent = 'Improvement ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');
1061
+ };
1062
+ show(false);
1063
+ const advance = (e) => { e.stopPropagation(); at = (at + 1) % raises.length; show(true); };
1064
+ cycle.addEventListener('click', advance);
1065
+ cycle.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); advance(e); } });
1066
+ });
1067
+
638
1068
  // Deal from the stack: cards begin piled at the grid's center, blurred,
639
1069
  // then travel to their seats with a stagger.
640
1070
  const cards = [...document.querySelectorAll('.card')];
@@ -668,56 +1098,303 @@ function page() {
668
1098
  }));
669
1099
  }
670
1100
 
671
- // Sketches stream in after the deal: poll each slot until the file lands,
1101
+ // Comps stream in after the deal: poll each slot until the file lands,
672
1102
  // then swap the shimmer for the image. Generation is genuinely slow and a
673
1103
  // sequential batch puts the last card many minutes out, so patience is the
674
1104
  // default: a slot only shows its inspiration as a stand-in when it has
675
1105
  // waited four minutes AND nothing has landed anywhere for four minutes, the
676
- // stand-in is labeled as such, and polling continues so the real sketch
1106
+ // stand-in is labeled as such, and polling continues so the real comp
677
1107
  // still swaps in whenever it arrives. Progress anywhere resets patience.
678
1108
  const landTracker = { last: Date.now() };
679
- document.querySelectorAll('.media.sketching').forEach(m => {
680
- const url = m.dataset.sketch;
681
- const img = m.querySelector('img.sketch');
682
- const note = m.querySelector('.sketch-note');
1109
+ const pollComp = (m) => {
1110
+ const url = m.dataset.comp;
1111
+ // A converted slot is the SAME node across flip cycles, so a probe from an
1112
+ // earlier cycle can still be in flight when the next one starts. Without a
1113
+ // generation stamp its late onload settles the new slot: shimmer stripped,
1114
+ // pending state cleared, comp still hidden, live poll stopped.
1115
+ const generation = String(Number(m.dataset.pollGen || 0));
1116
+ const current = () => String(Number(m.dataset.pollGen || 0)) === generation;
1117
+ const img = m.querySelector('img.comp');
1118
+ const note = m.querySelector('.comp-note');
683
1119
  const started = Date.now();
684
1120
  // A live elapsed count is the difference between "working" and "frozen".
685
- const tick = setInterval(() => { if (note) note.textContent = 'sketching · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
686
- const settle = () => { clearInterval(tick); m.classList.remove('sketching', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
687
- const standIn = () => {
1121
+ const tick = setInterval(() => { if (note) note.textContent = 'rendering · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
1122
+ const settle = () => { clearInterval(tick); m.classList.remove('comp-pending', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
1123
+ const fallback = () => {
688
1124
  const pip = m.querySelector('.pip img');
689
- if (!pip || m.classList.contains('stand-in')) return;
690
- img.src = pip.getAttribute('src'); img.hidden = false;
691
- m.classList.add('stand-in');
692
- m.querySelector('.shimmer')?.remove();
693
- clearInterval(tick);
694
- const label = document.createElement('p');
695
- label.className = 'stand-in-label';
696
- label.textContent = 'inspiration · sketch pending';
697
- m.appendChild(label);
1125
+ if (pip) {
1126
+ if (m.classList.contains('stand-in')) return false;
1127
+ img.src = pip.getAttribute('src'); img.hidden = false;
1128
+ m.classList.add('stand-in');
1129
+ m.querySelector('.shimmer')?.remove();
1130
+ clearInterval(tick);
1131
+ const label = document.createElement('p');
1132
+ label.className = 'stand-in-label';
1133
+ label.textContent = 'inspiration · comp pending';
1134
+ m.appendChild(label);
1135
+ return false;
1136
+ }
1137
+
1138
+ // No comp and no inspiration is the text-only card the payload would
1139
+ // have rendered without a comp declaration. Bring the complete read
1140
+ // forward before removing the now-unreachable back face.
1141
+ const card = m.closest('.card');
1142
+ const front = card?.querySelector('.face.front');
1143
+ const body = front?.querySelector('.body');
1144
+ const back = card?.querySelector('.face.back');
1145
+ const textOnlyFacts = m.querySelector('template.text-only-facts');
1146
+ const choose = body?.querySelector(':scope > button.choose');
1147
+ if (body && textOnlyFacts && choose) {
1148
+ const plainDetail = body.querySelector(':scope > .detail:not(.more)');
1149
+ [...body.children].filter((el) => el.classList.contains('fact') || el.matches('.detail.more')).forEach((el) => el.remove());
1150
+ choose.before(textOnlyFacts.content.cloneNode(true));
1151
+ if (plainDetail) choose.before(plainDetail);
1152
+ }
1153
+ card?.classList.remove('flipped');
1154
+ front?.classList.add('text-only');
1155
+ back?.remove();
1156
+ settle();
1157
+ m.remove();
1158
+ return true;
698
1159
  };
699
1160
  const tryLoad = () => {
1161
+ // A slot the user flipped back out of either leaves the DOM or, when it
1162
+ // was an inspiration face converted in place, stays and loses its
1163
+ // pending state. Either way its loop is done.
1164
+ if (!m.isConnected || !m.classList.contains('comp-pending') || !current()) { clearInterval(tick); return; }
700
1165
  const probe = new Image();
701
- probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); };
1166
+ probe.onload = () => {
1167
+ // A stale generation also ends this run's clock. tryLoad clears it on
1168
+ // re-entry, and an in-flight probe that finishes stale schedules no
1169
+ // re-entry, so returning without clearing ran the interval for the rest
1170
+ // of the page's life.
1171
+ if (!current()) { clearInterval(tick); return; }
1172
+ landTracker.last = Date.now();
1173
+ const target = m.querySelector('img.comp') || img;
1174
+ target.src = probe.src;
1175
+ target.hidden = false;
1176
+ settle();
1177
+ };
702
1178
  probe.onerror = () => {
1179
+ if (!current()) { clearInterval(tick); return; }
703
1180
  const quiet = Date.now() - landTracker.last > 240000;
704
- if (Date.now() - started > 240000 && quiet) standIn();
1181
+ if (Date.now() - started > 240000 && quiet && fallback()) return;
705
1182
  setTimeout(tryLoad, m.classList.contains('stand-in') ? 5000 : 2500);
706
1183
  };
707
1184
  probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
708
1185
  };
709
1186
  tryLoad();
1187
+ };
1188
+ document.querySelectorAll('.media.comp-pending').forEach(pollComp);
1189
+
1190
+ // Build-path toggle: the default is the round's recorded preference and
1191
+ // flipping binds this session only. Flipping code to comp swaps every
1192
+ // reserve slot (data-comp-slot) to its shimmer and tells the server, so
1193
+ // the waiting agent starts generating; flipping back is free: pending
1194
+ // slots return to their wireframes, a comp that already landed stays.
1195
+ const bp = document.getElementById('build-path');
1196
+ if (bp) {
1197
+ const notes = {
1198
+ comp: 'An image sets the bar first and the build must match it. Bolder composition; comps render before code.',
1199
+ code: 'Code builds directly; the ambition is written into the contract and audited at the finish. Leaner, faster.',
1200
+ };
1201
+ const noteEl = bp.querySelector('[data-bp-note]');
1202
+ let current = bp.dataset.default;
1203
+ const set = (value) => {
1204
+ current = value;
1205
+ bp.querySelectorAll('.bp-opt').forEach(b => {
1206
+ const on = b.dataset.bp === value;
1207
+ b.classList.toggle('active', on);
1208
+ b.setAttribute('aria-checked', String(on));
1209
+ });
1210
+ if (noteEl) noteEl.textContent = notes[value];
1211
+ };
1212
+ set(current);
1213
+ const INSPO_TITLE = 'Inspiration: the world this direction draws from. Your page will not look like this image.';
1214
+ // Every media slot carries the expand affordance. A slot built here after
1215
+ // the deal used to ship without one, so a comp the user waited minutes for
1216
+ // could not be opened.
1217
+ const ensureChips = (m) => {
1218
+ if (m.querySelector('.chips')) return;
1219
+ const tpl = document.getElementById('tpl-expand-chip');
1220
+ if (!tpl) return;
1221
+ const chips = document.createElement('div');
1222
+ chips.className = 'chips';
1223
+ chips.appendChild(tpl.content.cloneNode(true));
1224
+ m.appendChild(chips);
1225
+ };
1226
+ const shimmerHtml = '<div class="shimmer"><span class="comp-note">rendering&hellip;</span></div><img class="comp" alt="" hidden>';
1227
+ const enterComp = () => {
1228
+ document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
1229
+ const front = card.querySelector('.face.front');
1230
+ if (!front || front.querySelector('.media.comp-pending') || front.querySelector('.media img.comp:not([hidden])')) return;
1231
+ // On a code-led card the inspiration IS the face. Comp-first demotes it
1232
+ // to the corner, so convert that slot in place rather than inserting a
1233
+ // second one: two stacked images say the catalog art and the comp are
1234
+ // peers, and the whole point of the corner is that they are not.
1235
+ const inspo = front.querySelector('.media:not(.wire):not(.comp-pending)');
1236
+ let m;
1237
+ if (inspo) {
1238
+ m = inspo;
1239
+ m.dataset.compRestore = 'inspiration';
1240
+ m.dataset.comp = card.dataset.compSlot;
1241
+ m.classList.add('comp-pending');
1242
+ m.removeAttribute('title');
1243
+ m.querySelector('.media-label')?.remove();
1244
+ const art = m.querySelector(':scope > img');
1245
+ if (art) {
1246
+ const pip = document.createElement('figure');
1247
+ pip.className = 'pip';
1248
+ pip.title = INSPO_TITLE;
1249
+ const cap = document.createElement('figcaption');
1250
+ cap.textContent = 'inspiration';
1251
+ pip.append(art, cap);
1252
+ m.appendChild(pip);
1253
+ }
1254
+ m.insertAdjacentHTML('afterbegin', shimmerHtml);
1255
+ } else {
1256
+ m = document.createElement('div');
1257
+ m.className = 'media comp-pending';
1258
+ m.dataset.comp = card.dataset.compSlot;
1259
+ m.innerHTML = shimmerHtml;
1260
+ const wireEl = front.querySelector('.media.wire');
1261
+ if (wireEl) { wireEl.hidden = true; front.insertBefore(m, wireEl); }
1262
+ else { front.classList.remove('text-only'); front.insertBefore(m, front.querySelector('.body')); }
1263
+ }
1264
+ ensureChips(m);
1265
+ pollComp(m);
1266
+ });
1267
+ };
1268
+ const exitComp = () => {
1269
+ document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
1270
+ const front = card.querySelector('.face.front');
1271
+ const pending = front?.querySelector('.media.comp-pending');
1272
+ if (!pending) return; // landed comps stay; they exist either way
1273
+ // A converted slot is restored, not removed: the inspiration goes back
1274
+ // to being the face, so flipping back leaves the card as it was dealt.
1275
+ if (pending.dataset.compRestore === 'inspiration') {
1276
+ // Everything the pending state added has to leave, presentation
1277
+ // included: a slot that reached stand-in kept its "inspiration comp
1278
+ // pending" label beside a fresh one, and a slot whose art had failed
1279
+ // came back still marked unavailable.
1280
+ pending.classList.remove('comp-pending', 'stand-in');
1281
+ delete pending.dataset.compRestore;
1282
+ delete pending.dataset.comp;
1283
+ pending.dataset.pollGen = String(Number(pending.dataset.pollGen || 0) + 1);
1284
+ pending.querySelector('.shimmer')?.remove();
1285
+ pending.querySelector('img.comp')?.remove();
1286
+ pending.querySelector('.stand-in-label')?.remove();
1287
+ pending.querySelectorAll('.media-label').forEach((el) => el.remove());
1288
+ const pip = pending.querySelector('.pip');
1289
+ const art = pip?.querySelector('img');
1290
+ if (art) pending.insertBefore(art, pending.firstChild);
1291
+ pip?.remove();
1292
+ // No art means the image failed to load before the flip, so hand the
1293
+ // slot back to the same honest treatment rather than calling it art.
1294
+ const label = document.createElement('p');
1295
+ label.className = 'media-label';
1296
+ label.textContent = art ? 'inspiration' : 'artwork unavailable';
1297
+ pending.insertBefore(label, pending.querySelector('.chips'));
1298
+ if (art) pending.title = INSPO_TITLE; else pending.classList.add('unavailable');
1299
+ return;
1300
+ }
1301
+ pending.remove();
1302
+ const wireEl = front.querySelector('.media.wire');
1303
+ if (wireEl) wireEl.hidden = false;
1304
+ else if (!front.querySelector('.media')) front.classList.add('text-only');
1305
+ });
1306
+ };
1307
+ const apply = (value) => {
1308
+ set(value);
1309
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
1310
+ if (value === 'comp') enterComp(); else exitComp();
1311
+ };
1312
+ // Flipping to comp starts real generation, so it confirms first; the
1313
+ // flip back is free and applies immediately.
1314
+ const confirm = document.getElementById('bp-confirm');
1315
+ const closeConfirm = () => { confirm.classList.remove('open'); confirm.hidden = true; };
1316
+ confirm.querySelector('[data-confirm]').addEventListener('click', () => { closeConfirm(); apply('comp'); });
1317
+ confirm.querySelector('[data-cancel]').addEventListener('click', closeConfirm);
1318
+ confirm.addEventListener('click', (e) => { if (e.target === confirm) closeConfirm(); });
1319
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !confirm.hidden) closeConfirm(); });
1320
+ bp.querySelectorAll('.bp-opt').forEach(b => b.addEventListener('click', () => {
1321
+ const value = b.dataset.bp;
1322
+ if (value === current) return;
1323
+ if (value === 'comp') {
1324
+ confirm.hidden = false;
1325
+ requestAnimationFrame(() => confirm.classList.add('open'));
1326
+ return;
1327
+ }
1328
+ apply(value);
1329
+ }));
1330
+ }
1331
+
1332
+ // A declared image that never loads (missing catalog asset, offline shell)
1333
+ // must not sit as a dark void: the slot collapses to the card's own
1334
+ // palette, labeled honestly, and the card competes on its facts. Sketch
1335
+ // slots are excluded; their polling owns the wait.
1336
+ const artFailed = (img) => {
1337
+ const m = img.closest('.media');
1338
+ if (!m || m.classList.contains('comp-pending') || m.classList.contains('unavailable')) return;
1339
+ m.classList.add('unavailable');
1340
+ const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
1341
+ 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(', ') + ')';
1342
+ m.querySelector('.media-label')?.remove();
1343
+ m.querySelector('.chip.expand')?.remove();
1344
+ m.removeAttribute('title');
1345
+ img.remove();
1346
+ const label = document.createElement('p');
1347
+ label.className = 'media-label';
1348
+ label.textContent = 'artwork unavailable';
1349
+ m.appendChild(label);
1350
+ };
1351
+ document.querySelectorAll('.media:not(.comp-pending) > img').forEach(img => {
1352
+ if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
1353
+ else img.addEventListener('error', () => artFailed(img), { once: true });
1354
+ });
1355
+ // A broken inspiration PIP or thumb just leaves; nothing depends on it.
1356
+ document.querySelectorAll('.pip img, .inspo img').forEach(img => {
1357
+ const gone = () => img.closest('.pip, .inspo')?.remove();
1358
+ if (img.complete && img.naturalWidth === 0) gone();
1359
+ else img.addEventListener('error', gone, { once: true });
710
1360
  });
711
1361
 
712
- // Inspiration PIP opens the full catalog card in the lightbox.
713
- document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
714
- e.stopPropagation();
715
- const img = p.querySelector('img');
716
- if (!img) return;
717
- lightboxImg.src = img.getAttribute('src');
718
- lightbox.hidden = false;
719
- requestAnimationFrame(() => lightbox.classList.add('open'));
720
- }));
1362
+ // Every zoom target resolves in ONE delegated listener, in priority order.
1363
+ // Delegation is what lets a slot built by a build-path flip work at all, and
1364
+ // a single listener is what keeps the targets from fighting: stopPropagation
1365
+ // ends bubbling, not other listeners on the same target, so split across two
1366
+ // handlers a click on the corner inspiration opened the inspiration and then
1367
+ // the comp overwrote it in the lightbox. Per-element handlers elsewhere (the
1368
+ // flip chip, the raise cycler) still stop bubbling before the event lands
1369
+ // here, so they keep their own behavior.
1370
+ document.addEventListener('click', (e) => {
1371
+ const target = e.target;
1372
+ if (!target || !target.closest) return;
1373
+ // The corner inspiration wins over the slot it sits inside.
1374
+ const pip = target.closest('.pip, .inspo');
1375
+ if (pip) {
1376
+ const art = pip.querySelector('img');
1377
+ if (art) openLightbox(art);
1378
+ return;
1379
+ }
1380
+ const chip = target.closest('.chip');
1381
+ if (chip) {
1382
+ // Only expand zooms. Any other chip owns its click and must not fall
1383
+ // through to the media underneath it.
1384
+ if (!chip.classList.contains('expand')) return;
1385
+ const card = chip.closest('.card');
1386
+ const face = card && card.classList.contains('flipped') ? '.face.back' : '.face.front';
1387
+ const shown = card && card.querySelector(face + ' .media img:not([hidden])');
1388
+ if (shown && shown.getAttribute('src')) openLightbox(shown);
1389
+ return;
1390
+ }
1391
+ // The whole image is the zoom target, not just the chip; the chip stays as
1392
+ // the visible affordance.
1393
+ const media = target.closest('.media');
1394
+ if (!media) return;
1395
+ const art = media.querySelector(':scope > img:not([hidden])');
1396
+ if (art && art.getAttribute('src')) openLightbox(art);
1397
+ });
721
1398
 
722
1399
  // Deck paging: arrows appear only when the deck overflows its axis, page
723
1400
  // one card at a time, and follow the aspect-ratio flip between row and column.
@@ -757,7 +1434,7 @@ function page() {
757
1434
  const ambient = document.getElementById('ambient');
758
1435
  document.querySelectorAll('.card').forEach(card => {
759
1436
  card.addEventListener('mouseenter', () => {
760
- const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img');
1437
+ const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img') || card.querySelector('.face.front .inspo img');
761
1438
  if (!art || !art.getAttribute('src')) return;
762
1439
  ambient.style.backgroundImage = 'url("' + art.getAttribute('src') + '")'; ambient.style.opacity = '1';
763
1440
  });
@@ -767,16 +1444,13 @@ function page() {
767
1444
  // Expand: lightbox for whichever face is showing.
768
1445
  const lightbox = document.getElementById('lightbox');
769
1446
  const lightboxImg = lightbox.querySelector('img');
770
- document.querySelectorAll('.expand').forEach(b => b.addEventListener('click', (e) => {
771
- e.stopPropagation();
772
- const card = b.closest('.card');
773
- const face = card.classList.contains('flipped') ? '.face.back' : '.face.front';
774
- const img = card.querySelector(face + ' .media img:not([hidden])');
775
- if (!img || !img.getAttribute('src')) return;
1447
+ // Declared, not assigned to a const, so the delegated handlers above can call
1448
+ // it wherever they sit in this file.
1449
+ function openLightbox(img) {
776
1450
  lightboxImg.src = img.getAttribute('src');
777
1451
  lightbox.hidden = false;
778
1452
  requestAnimationFrame(() => lightbox.classList.add('open'));
779
- }));
1453
+ }
780
1454
  // Portrait art (native / mobile-first surfaces): the slot takes the
781
1455
  // image's own ratio so nothing crops, and the whole deck narrows so
782
1456
  // portrait cards sit side by side. Load events don't bubble; capture.
@@ -790,28 +1464,67 @@ function page() {
790
1464
  document.querySelector('.grid')?.classList.add('portrait-media');
791
1465
  }
792
1466
  }, true);
793
-
794
- // The whole image is the zoom target, not just the expand chip; the chip
795
- // stays as the visible affordance. Chip and PIP handlers stop propagation,
796
- // so this fires only for clicks on the art itself.
797
- document.querySelectorAll('.media').forEach(m => m.addEventListener('click', () => {
798
- const img = m.querySelector(':scope > img:not([hidden])');
799
- if (!img || !img.getAttribute('src')) return;
800
- lightboxImg.src = img.getAttribute('src');
801
- lightbox.hidden = false;
802
- requestAnimationFrame(() => lightbox.classList.add('open'));
803
- }));
804
1467
  const closeLightbox = () => { lightbox.classList.remove('open'); setTimeout(() => { lightbox.hidden = true; }, 250); };
805
1468
  lightbox.addEventListener('click', closeLightbox);
806
1469
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
807
1470
  document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
808
- document.getElementById('reroll')?.addEventListener('click', async () => {
809
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
1471
+ const dealAgain = async (register) => {
1472
+ // Quiet at the click, not after the fly-out: the POST round-trip plus
1473
+ // the 700ms animation was a window where a second click posted another
1474
+ // re-roll and renewed the delivery deadline.
1475
+ document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
1476
+ try {
1477
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
1478
+ } catch {
1479
+ document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
1480
+ return;
1481
+ }
1482
+ await awaitNextRound(true);
1483
+ };
1484
+ async function awaitNextRound(animate, budgetMs = ${idleGraceMs}) {
810
1485
  const grid = document.querySelector('.grid');
1486
+ let poll;
1487
+ let misses = 0;
1488
+ const shuffleStart = Date.now();
1489
+ const stall = (message) => {
1490
+ clearInterval(poll);
1491
+ // A stalled page is an abandoned flow: keep heartbeating and the
1492
+ // daemon never reaches its idle grace, so --wait spins on WAITING
1493
+ // forever. Go silent and let the server reclaim itself. Reload must
1494
+ // not undo that silence: an unconditional reload re-serves the same
1495
+ // unresolved round and its fresh page beats again, so check for a
1496
+ // delivered hand first and only reload when one exists. The re-roll
1497
+ // buttons and the canon exit go too: a stalled page served already
1498
+ // expired never disabled them, a re-roll would renew the deadline the
1499
+ // stall just enforced, and a canon pick would overwrite a re-roll
1500
+ // --wait already collected, closing the table under the agent.
1501
+ clearInterval(beatTimer);
1502
+ document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
1503
+ // Silence is for heartbeats only: a hand delivered after the deadline
1504
+ // must still land without a click, so a beat-free watch keeps checking
1505
+ // and reloads into it. /next-status never beats, so the daemon's idle
1506
+ // grace still reclaims a flow nobody resumes.
1507
+ const watch = setInterval(async () => {
1508
+ try { if ((await (await fetch('/next-status')).json()).ready) { clearInterval(watch); location.reload(); } } catch { /* server gone; the screen already says so */ }
1509
+ }, 1500);
1510
+ grid.innerHTML = '<div class="stall"><p>' + message + '</p><button type="button" class="choose">Reload</button></div>';
1511
+ grid.querySelector('.stall .choose').addEventListener('click', async () => {
1512
+ try {
1513
+ if ((await (await fetch('/next-status')).json()).ready) { location.reload(); return; }
1514
+ grid.querySelector('.stall p').textContent = 'Still nothing to deal. Check the agent session, or answer in the chat instead.';
1515
+ } catch {
1516
+ grid.querySelector('.stall p').textContent = 'The question server went away. Ask the agent to restart it, or answer in the chat instead.';
1517
+ }
1518
+ });
1519
+ };
1520
+ // A refresh that lands after the delivery deadline has nothing left to
1521
+ // wait for: stall before the heartbeat timer's first tick can fire, so
1522
+ // the served page stays silent.
1523
+ if (budgetMs <= 0) { stall('The next hand never arrived. Check the agent session, then reload.'); return; }
811
1524
  const cardsNow = [...grid.querySelectorAll('.card')];
812
- const g = grid.getBoundingClientRect();
813
- const cx = g.left + g.width / 2, cy = g.top + g.height / 2;
814
- if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
1525
+ if (animate && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
1526
+ const g = grid.getBoundingClientRect();
1527
+ const cx = g.left + g.width / 2, cy = g.top + g.height / 2;
815
1528
  cardsNow.forEach((card, i) => {
816
1529
  const r = card.getBoundingClientRect();
817
1530
  card.style.transition = 'transform .5s cubic-bezier(.5,0,.75,0) ' + (i * 60) + 'ms, opacity .4s ease ' + (i * 60 + 120) + 'ms, filter .45s ease ' + (i * 60) + 'ms';
@@ -823,29 +1536,100 @@ function page() {
823
1536
  }
824
1537
  const cardHeight = cardsNow[0] ? cardsNow[0].getBoundingClientRect().height : 0;
825
1538
  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('');
826
- document.getElementById('reroll')?.setAttribute('disabled', '');
827
- const poll = setInterval(async () => {
1539
+ // Canon goes quiet with the re-roll buttons: a pick posted mid-wait can
1540
+ // never be collected once --wait has the re-roll, only close the table.
1541
+ document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
1542
+ // The wait must be able to end: a dead server rejects every tick and a
1543
+ // round nobody delivers stays ready:false forever, and both used to spin
1544
+ // the skeletons indefinitely. Distinguish them, say so, and offer a way
1545
+ // out. The delivery deadline is the server's own idle grace, so the page
1546
+ // never gives up on a server that would still accept the hand.
1547
+ poll = setInterval(async () => {
828
1548
  try {
829
1549
  const status = await (await fetch('/next-status')).json();
1550
+ misses = 0;
830
1551
  if (status.ready) { clearInterval(poll); location.reload(); }
831
- } catch { /* server briefly busy */ }
1552
+ else if (Date.now() - shuffleStart > budgetMs) stall('The next hand never arrived. Check the agent session, then reload.');
1553
+ } catch {
1554
+ misses += 1;
1555
+ if (misses >= 8) stall('The question server went away. Ask the agent to restart it, or answer in the chat instead.');
1556
+ }
832
1557
  }, 1200);
833
- });
1558
+ }
1559
+ document.getElementById('reroll')?.addEventListener('click', () => dealAgain());
1560
+ document.getElementById('reroll-safer')?.addEventListener('click', () => dealAgain('safer'));
1561
+ document.getElementById('reroll-bolder')?.addEventListener('click', () => dealAgain('bolder'));
1562
+ // A native refresh must not resurrect an answered round: while the server
1563
+ // holds a collected re-roll or followup pick with no replacement delivered,
1564
+ // it serves the page in waiting mode and the refresh re-enters the same
1565
+ // bounded wait, with only the time the original deadline has left, instead
1566
+ // of showing dead cards whose heartbeat props the daemon forever.
1567
+ ${waiting ? `awaitNextRound(false, ${waitBudgetMs});` : ''}
834
1568
  </script>`;
835
1569
  }
836
1570
 
1571
+ // Browsers omit the :80 suffix on the default HTTP port, so a server on
1572
+ // --port 80 sees bare loopback hosts and origins.
1573
+ function allowedHost(host, port) {
1574
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
1575
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
1576
+ }
1577
+
1578
+ function allowedOrigin(origin, port) {
1579
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
1580
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
1581
+ }
1582
+
1583
+ function rejectDetachedPost(req, res, url, port) {
1584
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
1585
+ res.writeHead(401); res.end(); return true;
1586
+ }
1587
+ const origin = req.headers.origin;
1588
+ if (origin && !allowedOrigin(origin, port)) {
1589
+ res.writeHead(403); res.end(); return true;
1590
+ }
1591
+ return false;
1592
+ }
1593
+
837
1594
  const server = http.createServer((req, res) => {
838
- if (req.method === 'GET' && req.url === '/') {
1595
+ const { port } = server.address();
1596
+ if (!allowedHost(req.headers.host, port)) {
1597
+ res.writeHead(403); res.end(); return;
1598
+ }
1599
+ let url;
1600
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
1601
+ catch { res.writeHead(400); res.end(); return; }
1602
+ const pathname = url.pathname;
1603
+ if (req.method === 'GET' && pathname === '/') {
839
1604
  const pending = nextFile();
840
1605
  if (pending && fs.existsSync(pending)) {
841
- try { loadRound(fs.readFileSync(pending, 'utf8')); fs.rmSync(pending); } catch { /* keep current round */ }
1606
+ // A next file the round cannot load has to leave the disk either way:
1607
+ // kept, /next-status stays ready:true and the waiting page reloads
1608
+ // into the same failure without bound.
1609
+ try { loadRound(fs.readFileSync(pending, 'utf8')); } catch { /* keep current round */ }
1610
+ try { fs.rmSync(pending); } catch { /* already gone */ }
1611
+ // The claim consumes the file the idle-exit hold reads, and the
1612
+ // reloading page cannot beat until it has parsed: stamp the claim so
1613
+ // the same bounded grace covers the gap between them. Persisted too,
1614
+ // because --wait watches the same gap from outside this process and
1615
+ // would otherwise read the stale beat as a closed page.
1616
+ server.lastClaimAt = Date.now();
1617
+ if (detachedKey) {
1618
+ try {
1619
+ const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8'));
1620
+ state.claimedAt = server.lastClaimAt;
1621
+ fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state));
1622
+ } catch { /* state file recreated on next beat */ }
1623
+ }
842
1624
  }
843
1625
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
844
- res.end(page());
1626
+ res.end(page(awaitingNext));
845
1627
  return;
846
1628
  }
847
- if (req.method === 'POST' && req.url === '/heartbeat') {
1629
+ if (req.method === 'POST' && pathname === '/heartbeat') {
1630
+ if (rejectDetachedPost(req, res, url, port)) return;
848
1631
  res.writeHead(204); res.end();
1632
+ server.lastBeatSeen = Date.now();
849
1633
  if (detachedKey) {
850
1634
  const now = Date.now();
851
1635
  if (!server.lastBeatWrite || now - server.lastBeatWrite > 4000) {
@@ -859,13 +1643,13 @@ const server = http.createServer((req, res) => {
859
1643
  }
860
1644
  return;
861
1645
  }
862
- if (req.method === 'GET' && req.url === '/next-status') {
1646
+ if (req.method === 'GET' && pathname === '/next-status') {
863
1647
  const pending = nextFile();
864
1648
  res.writeHead(200, { 'content-type': 'application/json' });
865
1649
  res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
866
1650
  return;
867
1651
  }
868
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
1652
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
869
1653
  if (imageMatch) {
870
1654
  const abs = localImages[Number(imageMatch[1])];
871
1655
  if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -878,7 +1662,34 @@ const server = http.createServer((req, res) => {
878
1662
  fs.createReadStream(abs).pipe(res);
879
1663
  return;
880
1664
  }
881
- if (req.method === 'POST' && req.url === '/answer') {
1665
+ if (req.method === 'POST' && pathname === '/build-path') {
1666
+ if (rejectDetachedPost(req, res, url, port)) return;
1667
+ let body = '';
1668
+ req.on('data', (chunk) => { body += chunk; });
1669
+ req.on('end', () => {
1670
+ let value = null;
1671
+ try { value = JSON.parse(body).value; } catch { /* ignore */ }
1672
+ if (value === 'comp' || value === 'code') {
1673
+ const wasComp = liveBuildPath === 'comp';
1674
+ liveBuildPath = value;
1675
+ // Only a flip TO comp needs the agent mid-round: comps must start
1676
+ // rendering into the declared slots. The reverse is free.
1677
+ if (detachedKey && value === 'comp' && !wasComp) {
1678
+ fs.mkdirSync(QUESTION_DIR, { recursive: true });
1679
+ fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n');
1680
+ }
1681
+ }
1682
+ // Answer only once the flip is on disk. Responding first raced the
1683
+ // caller: the 200 reached the client (a separate process) while this
1684
+ // one could still be preempted before the write landed, so a poller
1685
+ // that trusted the 200 could look for the flip file and miss it.
1686
+ res.writeHead(200, { 'content-type': 'application/json' });
1687
+ res.end('{"ok":true}');
1688
+ });
1689
+ return;
1690
+ }
1691
+ if (req.method === 'POST' && pathname === '/answer') {
1692
+ if (rejectDetachedPost(req, res, url, port)) return;
882
1693
  let body = '';
883
1694
  req.on('data', (chunk) => { body += chunk; });
884
1695
  req.on('end', () => {
@@ -887,22 +1698,35 @@ const server = http.createServer((req, res) => {
887
1698
  let parsed = {};
888
1699
  try { parsed = JSON.parse(body); } catch { /* empty steer */ }
889
1700
  const chosen = options.find((o) => o.id === parsed.optionId);
1701
+ const isReroll = parsed.optionId === 'reroll';
1702
+ // A followup round's pick is not terminal: the table stays open for the
1703
+ // next round (--update), exactly like a re-roll. Detached mode only;
1704
+ // the blocking mode has no update channel, so its picks stay terminal.
1705
+ const followupOpen = Boolean(detachedKey) && payload.followup === true && !isReroll;
890
1706
  const answer = JSON.stringify({
891
1707
  optionId: parsed.optionId ?? null,
892
1708
  steer: parsed.steer ?? '',
1709
+ ...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}),
1710
+ ...(followupOpen ? { followup: true } : {}),
893
1711
  ...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
894
- ...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
1712
+ ...((chosen?.comp ?? chosen?.sketch) ? { comp: chosen.comp ?? chosen.sketch } : {}),
1713
+ ...(liveBuildPath && !isReroll ? { buildPath: liveBuildPath, buildPathFlipped: liveBuildPath !== (buildPathDefault?.value ?? null) } : {}),
895
1714
  });
896
- const isReroll = parsed.optionId === 'reroll';
1715
+ // The delivery deadline is single-issue: a duplicate answer racing the
1716
+ // page's disable must not restamp the allowance already inherited.
1717
+ const wasAwaiting = awaitingNext;
1718
+ awaitingNext = (isReroll || followupOpen) && Boolean(detachedKey);
1719
+ if (awaitingNext && !wasAwaiting) awaitingNextSince = Date.now();
897
1720
  if (detachedKey) {
898
1721
  fs.mkdirSync(QUESTION_DIR, { recursive: true });
899
1722
  fs.writeFileSync(answerFile(detachedKey), answer + '\n');
900
1723
  } else {
901
1724
  printAnswer(answer);
902
1725
  }
903
- // A re-roll in detached mode keeps the table open: the client shows a
904
- // loading hand and reloads when --update delivers the next round.
905
- if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150);
1726
+ // A re-roll or followup pick in detached mode keeps the table open: the
1727
+ // client shows a loading hand and reloads when --update delivers the
1728
+ // next round.
1729
+ if (!((isReroll || followupOpen) && detachedKey)) setTimeout(() => process.exit(0), 150);
906
1730
  });
907
1731
  return;
908
1732
  }
@@ -920,13 +1744,40 @@ server.listen(portArg, '127.0.0.1', () => {
920
1744
  console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
921
1745
  }
922
1746
  if (!hasFlag('no-open')) {
923
- const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
924
- try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ }
925
- }
926
- if (timeoutSec > 0) {
927
- setTimeout(() => {
928
- console.log('serve-question: timed out with no answer');
929
- process.exit(2);
930
- }, timeoutSec * 1000).unref?.();
1747
+ openSystemBrowser(url);
931
1748
  }
1749
+ // The timeout bounds the wait for a page, never the user's decision: an
1750
+ // absolute guillotine counted from start used to kill the server under a
1751
+ // still-open tab (a slow re-rolled round easily outlived it), leaving the
1752
+ // page polling skeletons that could never resolve. Once the page beats,
1753
+ // the server's lifetime tracks the beats, and it exits only after the idle
1754
+ // grace passes with none, long enough to survive a closed laptop lid.
1755
+ // --timeout 0 waits for a page forever, but the idle grace still applies
1756
+ // once one has beat: a page that arrived and went silent is a closed tab,
1757
+ // and no timeout setting should let that daemon leak.
1758
+ const startedAt = Date.now();
1759
+ const lifetime = setInterval(() => {
1760
+ if (!server.lastBeatSeen) {
1761
+ if (timeoutSec > 0 && Date.now() - startedAt > timeoutSec * 1000) {
1762
+ console.log('serve-question: timed out with no answer');
1763
+ process.exit(2);
1764
+ }
1765
+ } else if (Date.now() - server.lastBeatSeen > idleGraceMs) {
1766
+ // A hand delivered moments before this deadline still gets its claim
1767
+ // window: the stalled page's watch reloads into it and beats again
1768
+ // within seconds, while a file unclaimed past the grace means no page
1769
+ // is coming back (the same verdict --wait reads from its age). The
1770
+ // claim itself holds the daemon too: GET / deletes the file before the
1771
+ // reloaded page can beat, so a tick in that gap must not exit under
1772
+ // the hand just claimed.
1773
+ const pending = nextFile();
1774
+ let deliveredAt = 0;
1775
+ if (pending) { try { deliveredAt = fs.statSync(pending).mtimeMs; } catch { /* nothing delivered */ } }
1776
+ if (Date.now() - Math.max(deliveredAt, server.lastClaimAt || 0) > NEXT_CLAIM_GRACE_MS) {
1777
+ console.log('serve-question: the page stopped beating and never came back; exiting');
1778
+ process.exit(2);
1779
+ }
1780
+ }
1781
+ }, 2000);
1782
+ lifetime.unref?.();
932
1783
  });