Files
pbakaus_impeccable/crates/context/src/question_page.rs
T
Paul BakausandGitHub 720628f4ea Fix: use system fonts and the light theme in the picker (#751)
Replace external fonts with system stacks and an outlined SVG logo. Adopt the light paper theme, refine Kinpaku accents, and preserve picker interactions with regression coverage.

AI assistance: Codex, under maintainer direction.
2026-09-06 12:44:15 -07:00

27 lines
68 KiB
Rust

//! Generated from skill/scripts/serve-question.mjs `page()` template by scratchpad/gen-page.mjs.
//! `@@N@@` marks the N-th `${...}` interpolation (see PAGE_EXPRS), filled by serve_question.rs.
//! Picker theme follows impeccable-site's paper-and-instruments system.
//! Inline logo outlines reuse site/public/assets/brand/impeccable-logo-on-light.svg
//! from impeccable-site at eb5348e48fcd94d0d74895488b985988a401a2e2, with a tight
//! viewBox and theme-aware fills. All remaining typography uses system fonts.
pub const PAGE: &str = "<!doctype html>\n<meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>@@1@@</title>\n<style>\n /* Paper-and-instruments palette from impeccable-site. The picker uses\n system fonts; its outlined wordmark needs no font file. */\n :root {\n color-scheme: light;\n --ks-kinpaku: oklch(84% 0.19 80.46);\n --ks-patina: oklch(49% 0.11 190);\n --ks-paper: oklch(97.8% 0 0);\n --ks-paper-raised: oklch(99.5% 0 0);\n --ks-paper-deep: oklch(95% 0 0);\n --ks-gray: oklch(92% 0 0);\n --ks-gray-2: oklch(88% 0 0);\n --ks-ink: oklch(13% 0 0);\n --ks-text: oklch(22% 0 0);\n --ks-text-muted: oklch(46% 0 0);\n --ks-text-faint: var(--ks-text-muted);\n --ks-rule: oklch(13% 0 0 / 0.12);\n --ks-edge: oklch(13% 0 0 / 0.45);\n --ks-instrument: oklch(24% 0 0);\n --ks-instrument-raised: oklch(31% 0 0);\n --ks-instrument-text: oklch(93% 0 0);\n --ks-lift: 0 1px 1px oklch(13% 0 0 / 0.05), 0 2px 3px oklch(13% 0 0 / 0.04), 0 6px 12px oklch(13% 0 0 / 0.05);\n --ks-cap-lift: inset 0 1px 0 oklch(100% 0 0 / 0.9), 0 1px 0 oklch(13% 0 0 / 0.14), 0 2px 3px oklch(13% 0 0 / 0.08);\n --ks-cap-press: inset 0 1px 2px oklch(13% 0 0 / 0.14);\n --ks-track-recess: inset 0 1px 3px oklch(13% 0 0 / 0.16), inset 0 -1px 0 oklch(100% 0 0 / 0.7);\n --ks-font: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --ks-font-display: var(--ks-font);\n --ks-mono: ui-monospace, \"SFMono-Regular\", Menlo, Consolas, monospace;\n /* Shared alignment for the content, snapping deck, and sticky footer. */\n --page-inset: max(clamp(1rem, 5vw, 4rem), calc((100vw - 90rem) / 2));\n }\n * { box-sizing: border-box; margin: 0; }\n body { background: var(--ks-paper); 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; }\n #ambient { display: none; }\n #scrim { display: none; }\n header, main, footer { position: relative; z-index: 1; }\n #lightbox { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: oklch(97.8% 0 0 / 0.96); cursor: zoom-out; opacity: 0; transition: opacity .25s ease; }\n #lightbox[hidden] { display: none; }\n #lightbox.open { opacity: 1; }\n #lightbox img { max-width: 94vw; max-height: 94vh; border: 1px solid var(--ks-rule); border-radius: 8px; box-shadow: var(--ks-lift); }\n header { width: 100%; max-width: 90rem; margin: 0 auto; }\n .brand { display: inline-flex; align-items: center; color: var(--ks-ink); }\n .brand svg { display: block; width: 164px; height: 30px; }\n .headline { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }\n .headline-die { flex: none; width: 26px; height: 26px; color: var(--ks-ink); }\n h1 { font-family: var(--ks-font); font-weight: 600; font-size: 2rem; letter-spacing: -0.025em; line-height: 1.2; color: var(--ks-ink); text-wrap: balance; }\n .question { color: var(--ks-text-muted); margin-top: .7rem; max-width: 52rem; }\n main { flex: 1; display: flex; align-items: center; width: 100%; max-width: 90rem; margin: 0 auto; }\n .stage { width: 100%; display: flex; flex-direction: column; gap: 1.5rem; }\n /* The deck bleeds to the viewport edges while the first card aligns with the\n content column; a carousel cut off at an invisible container edge reads as\n a rendering bug, but one cut off at the screen edge reads as more cards. */\n .deck-shell { position: relative; width: 100vw; margin-left: calc(50% - 50vw); }\n /* One row in a wide viewport, one column in a tall one; the deck scrolls on\n its axis with snap points and the arrows page it card by card. */\n .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; }\n .grid::-webkit-scrollbar { display: none; }\n /* Wide enough that the comp carries the card: at 27vw the imagery read\n as a thumbnail above a column of copy, and the copy won the attention\n contest the comp is supposed to win. */\n .grid > .card { flex: 0 0 clamp(24rem, 34vw, 34rem); scroll-snap-align: center; }\n /* Short landscape viewports (13-inch laptops): header, a 34vw card, and the\n footer do not fit 800px of height, so the headline compacts and the deck\n narrows. Height is the axis that gives; the sticky footer keeps the\n round's verbs on screen while a too-tall card scrolls. */\n @media (min-aspect-ratio: 1/1) and (max-height: 900px) {\n body { padding-top: 1.1rem; }\n h1 { font-size: 2rem; }\n .question { margin-top: .45rem; }\n .stage { gap: 1rem; }\n .grid > .card { flex-basis: clamp(20rem, 27vw, 27rem); }\n .grid > .card.declined { flex-basis: clamp(13rem, 18vw, 18rem); }\n }\n .nav { position: absolute; z-index: 6; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: var(--ks-paper-raised); border: 1px solid var(--ks-edge); color: var(--ks-ink); cursor: pointer; box-shadow: var(--ks-cap-lift); transition: border-color .2s, color .2s, opacity .2s; }\n .nav:hover { border-color: var(--ks-ink); color: var(--ks-patina); }\n .nav[disabled] { opacity: .25; cursor: default; }\n .nav[hidden] { display: none; }\n .nav svg { width: 16px; height: 16px; }\n .nav.prev { left: 14px; top: 50%; transform: translateY(-50%); }\n .nav.next { right: 14px; top: 50%; transform: translateY(-50%); }\n /* A side that hides more cards fades out; a hard edge means the end. */\n .fade { position: absolute; z-index: 5; pointer-events: none; opacity: 0; transition: opacity .3s ease; }\n .fade-prev { left: 0; top: 0; bottom: 0; width: 88px; background: linear-gradient(90deg, var(--ks-paper), transparent); }\n .fade-next { right: 0; top: 0; bottom: 0; width: 88px; background: linear-gradient(270deg, var(--ks-paper), transparent); }\n .deck-shell.can-prev .fade-prev { opacity: 1; }\n .deck-shell.can-next .fade-next { opacity: 1; }\n @media (max-aspect-ratio: 1/1) {\n .grid { flex-direction: column; overflow-x: hidden; overflow-y: auto; scroll-snap-type: y mandatory; max-height: min(68dvh, 44rem); scroll-padding-block: 6px; }\n .grid > .card { flex: 0 0 auto; }\n /* In the vertical deck the pager is the primary way forward, so it grows\n into a labeled pill instead of a bare chevron nobody notices. */\n .nav { width: auto; height: 38px; border-radius: 19px; padding: 0 16px; gap: 8px; background: var(--ks-paper-raised); font-family: var(--ks-font); font-size: .75rem; font-weight: 600; }\n .nav svg { transform: rotate(90deg); }\n .nav.prev::after { content: \"Back\"; }\n .nav.next::after { content: \"More\"; }\n .nav.prev { left: 50%; top: 6px; transform: translate(-50%, 0); }\n .nav.next { right: auto; left: 50%; top: auto; bottom: 6px; transform: translate(-50%, 0); }\n .fade-prev { top: 0; left: 0; right: 0; bottom: auto; width: auto; height: 72px; background: linear-gradient(180deg, var(--ks-paper), transparent); }\n .fade-next { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: 72px; background: linear-gradient(0deg, var(--ks-paper), transparent); }\n /* In the vertical deck the cross axis is horizontal: flex-start would\n shrink a declined card to content WIDTH, not height, so it stretches\n like every other card and its height is already its own. */\n .grid > .card.declined { align-self: stretch; }\n /* The sticky bar is a wide-viewport fix. Here it would sit over the\n deck's More pager and cost a third of a phone screen, and the deck\n already scrolls internally, so the footer stays in the page flow. */\n footer { position: static; width: auto; margin: 1rem 0 0; padding: .7rem 0 1.2rem; background: transparent; border-top: 0; backdrop-filter: none; }\n }\n .card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); }\n .card:hover { transform: rotate(0deg) translateY(-4px); }\n .card-inner { position: relative; height: 100%; transform-style: preserve-3d; transition: transform .7s cubic-bezier(.16, 1, .3, 1); }\n .card.flipped .card-inner { transform: rotateY(180deg); }\n .face { background: var(--ks-paper-raised); border: 1px solid var(--ks-rule); border-radius: 8px; box-shadow: var(--ks-lift); overflow: hidden; display: flex; flex-direction: column; backface-visibility: hidden; -webkit-backface-visibility: hidden; }\n .face.front { position: relative; height: 100%; }\n .face.back { position: absolute; inset: 0; transform: rotateY(180deg); }\n /* Only the visible face is interactive: a hidden backface still hit-tests\n in Chrome, so the front's pip would otherwise sit invisibly over the\n back's chips, showing its zoom cursor and eating the flip-back click. */\n .face.back { pointer-events: none; }\n .card.flipped .face.back { pointer-events: auto; }\n .card.flipped .face.front { pointer-events: none; }\n .face.lead { border-color: var(--ks-kinpaku); }\n .card:hover .face { border-color: var(--ks-edge); }\n .card:hover .face.lead { border-color: var(--ks-kinpaku); }\n @media (prefers-reduced-motion: reduce) { .card-inner { transition: none; } }\n .kicker { position: absolute; z-index: 2; top: 12px; left: 12px; padding: 4px 9px; background: var(--ks-paper-raised); border: 1px solid var(--ks-rule); color: var(--ks-ink); font-family: var(--ks-font); font-size: .6875rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; border-radius: 3px; }\n /* Text-only card: a grounded direction with no rendered card drops the media\n region entirely instead of reserving a blank 16:9 void. */\n .face.text-only .kicker { position: static; align-self: flex-start; margin: 14px 0 0 14px; }\n .face.text-only .body { padding-top: 12px; }\n /* 16/10 matches the landscape comp frame; portrait art overrides the\n slot with its own exact ratio at load (see the load listener), and the\n deck narrows so portrait cards line up side by side. */\n .media { position: relative; width: 100%; aspect-ratio: 16/10; flex: none; }\n .grid.portrait-media > .card { flex-basis: clamp(14rem, 19vw, 19rem); }\n .media img { width: 100%; height: 100%; object-fit: cover; display: block; background: linear-gradient(100deg, var(--ks-gray) 40%, var(--ks-gray-2) 50%, var(--ks-gray) 60%); }\n .media > img:not([hidden]) { cursor: zoom-in; }\n .face.back { background: var(--ks-paper-raised); }\n .back-bar { margin-top: auto; background: var(--ks-paper-raised); }\n .hero-blank { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-gray) 40%, var(--ks-gray-2) 50%, var(--ks-gray) 60%); }\n .back-bar { flex: none; flex-direction: row; align-items: center; justify-content: space-between; gap: .8rem; }\n .chips { position: absolute; z-index: 1; right: 10px; bottom: 10px; display: flex; gap: 6px; }\n .chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; font-family: var(--ks-font); font-size: .6875rem; font-weight: 500; color: var(--ks-instrument-text); background: var(--ks-instrument); border: 1px solid oklch(100% 0 0 / 0.3); border-radius: 5px; cursor: pointer; transition: color .2s, background .2s; }\n .chip:hover { color: var(--ks-instrument-text); background: var(--ks-instrument-raised); }\n .chip svg { width: 12px; height: 12px; }\n .body { padding: .95rem 1.1rem 1.2rem; display: flex; flex-direction: column; gap: .5rem; flex: 1; }\n .tier { font-family: var(--ks-mono); font-size: .6875rem; letter-spacing: .24em; text-transform: uppercase; color: var(--ks-text-faint); }\n h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 600; line-height: 1.35; color: var(--ks-ink); }\n .detail { color: var(--ks-text-muted); font-size: .88rem; white-space: pre-wrap; }\n .detail.more { font-size: .8rem; color: var(--ks-text-faint); }\n .thesis { color: var(--ks-text); font-size: .95rem; line-height: 1.45; }\n .identity { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 2px 0; }\n .swatches { display: inline-flex; gap: 4px; margin-right: 4px; }\n .swatches i { width: 18px; height: 18px; border-radius: 5px; border: 1px solid oklch(100% 0 0 / 0.18); box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.25); }\n .tag { font-family: var(--ks-mono); font-size: .6875rem; letter-spacing: .14em; text-transform: uppercase; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); border-radius: 4px; padding: 3px 7px; }\n .fact { font-size: .8rem; color: var(--ks-text-muted); line-height: 1.45; }\n .fact-label { display: inline-block; font-family: var(--ks-mono); font-size: .6875rem; letter-spacing: .18em; text-transform: uppercase; color: var(--ks-text-faint); margin-right: .55em; transform: translateY(-1px); }\n .fact.clamp { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }\n /* The back is the full read: first viewport, the case, the whole risk, and\n the board when the world has one. */\n .back-head { display: flex; align-items: center; justify-content: space-between; gap: .8rem; padding: 14px 14px 0; }\n .media.back-media { aspect-ratio: 16/6; }\n .media.back-media img { width: 100%; height: 100%; object-fit: cover; }\n .body.back-body { overflow-y: auto; flex: 1; scrollbar-width: thin; }\n /* Inspiration rides picture-in-picture: the catalog world explains where the\n direction comes from without promising what the build will look like. */\n /* Hovering the inspiration takes over the whole media region; the comp is\n the promise, the inspiration is a glance, so the glance must cost nothing. */\n .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-paper); 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: var(--ks-lift); }\n .pip img { display: block; width: 100%; height: 100%; object-fit: cover; }\n .pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .6875rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-instrument-text); text-align: center; padding: 3px 0 4px; background: var(--ks-instrument); }\n .pip:hover { left: 0; bottom: 0; width: 100%; height: 100%; border-radius: 0; z-index: 3; }\n .comp-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .6875rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }\n /* Catalog art standing in for a comp-less card is a reference, and says so\n on its face; the same pill later carries \"artwork unavailable\". */\n .media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .6875rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-instrument-text); padding: 3px 8px 4px; background: var(--ks-instrument); border: 1px solid var(--ks-rule); border-radius: 4px; }\n /* Art that never arrives collapses to the card's own palette (painted\n inline from its swatches) instead of sitting as a dark void wearing a\n zoom cursor; the scrim keeps the label legible over saturated fields,\n passes clicks through, and the flip chips stay above it. A card with no\n palette falls back to the quiet graphite field. */\n .media.unavailable { background: linear-gradient(100deg, var(--ks-gray) 40%, var(--ks-gray-2) 50%, var(--ks-gray) 60%); }\n .media.unavailable::after { content: \"\"; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }\n .media.unavailable .chips { z-index: 2; }\n /* A stand-in is honest about being one: dimmed, labeled, and replaced by\n the real comp whenever it lands. */\n .media.stand-in img.comp { filter: brightness(.72) saturate(.85); }\n .media.stand-in .pip { display: none; }\n .stand-in-label { position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; margin: 0; font-family: var(--ks-mono); font-size: .6875rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-instrument-text); text-align: center; padding: 4px 0 5px; background: var(--ks-instrument); }\n .media.comp-pending { position: relative; }\n .media.comp-pending .shimmer { position: absolute; inset: 0; }\n .media img.comp { position: relative; z-index: 1; }\n /* The generic .media img display:block would defeat [hidden] and float an\n empty block over the shimmer; an unloaded comp must truly not render. */\n .media img[hidden] { display: none; }\n /* Declined challengers: the weighing demoted them, so the card is narrower\n and quieter, its catalog art rides as a labeled thumb in the body, and\n the action reads \"Adopt anyway\". Adoptable, never deleted: the demoted\n row is the hand's proof of judgment. */\n /* Narrow AND short: without align-self the stretch default drags a thin\n declined card to the tallest contender's height, a strange stilt of a\n card beside the full hand. */\n .grid > .card.declined { flex: 0 0 clamp(15rem, 21vw, 21rem); align-self: flex-start; }\n .card.declined .face { background: var(--ks-gray); }\n .card.declined:hover .face { border-color: var(--ks-text-faint); }\n .card.declined h2 { font-size: 1rem; color: var(--ks-text); }\n .kicker.declined-k { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); }\n .card.declined button.choose { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font-size: .85rem; padding: 8px 22px; }\n .card.declined button.choose:hover { background: var(--ks-gray-2); border-color: var(--ks-text-muted); }\n /* Wireframe media: the code-led schematic. Quiet boxes in the card's own\n chrome; uniform salience across cards by construction, so it needs no\n parity rules. */\n .media.wire { background: var(--ks-paper); border-bottom: 1px solid var(--ks-rule); }\n .wire-field { position: absolute; inset: 12px 12px 26px; }\n .wire-region { position: absolute; border: 1px solid var(--ks-edge); border-radius: 3px; background: var(--ks-paper-raised); display: flex; align-items: center; justify-content: center; overflow: hidden; }\n .wire-region span { font-family: var(--ks-mono); font-size: .6875rem; letter-spacing: .1em; text-transform: uppercase; color: var(--ks-text-faint); text-align: center; padding: 2px 4px; }\n .wire-region.accent { border-color: var(--ks-patina); background: oklch(49% 0.11 190 / 0.06); }\n .wire-region.accent span { color: var(--ks-patina); }\n /* Thumb-scale inspiration: present, labeled, zoomable, and incapable of\n outshouting a text-only assigned card. */\n .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-paper); }\n .inspo img { display: block; width: 100%; height: 100%; object-fit: cover; }\n .inspo figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .6875rem; letter-spacing: .16em; text-transform: uppercase; color: var(--ks-instrument-text); text-align: center; padding: 2px 0 3px; background: var(--ks-instrument); }\n /* Raises: the improvements the dealt worlds donated to the assigned\n direction, each named for its donor world. Patina, not kinpaku:\n provenance, not a call to action. A quiet contained panel, never an\n accent side-tab. */\n .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; }\n .raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; }\n .raise .fact-label { color: var(--ks-patina); }\n /* Several kept ideas cycle instead of stacking: one visible at a time, a\n counter for the rest, the whole block advances on click. */\n .raises-cycle { cursor: pointer; transition: border-color .2s ease; }\n .raises-cycle:hover { border-color: oklch(70% 0.12 188 / 0.45); }\n .raises-cycle .raise { display: none; }\n .raises-cycle .raise.active { display: block; }\n .raises-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }\n .raises-head .fact-label { color: var(--ks-patina); }\n .raises-count { font-family: var(--ks-mono); font-size: .6875rem; letter-spacing: .14em; color: var(--ks-text-faint); }\n .raises-count::after { content: \" \\203A\"; }\n .raises-cycle:hover .raises-count { color: var(--ks-patina); }\n .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; }\n /* The standing exit as a card: present with full anatomy, never dressed as a\n contender. Graphite instead of kinpaku, and it never takes the lead ring. */\n .card.canon .face { border-color: var(--ks-rule); background: var(--ks-gray); }\n .card.canon:hover .face { border-color: var(--ks-text-faint); }\n .card.canon .kicker.standing { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); }\n .card.canon button.choose { background: transparent; color: var(--ks-text); border: 1px solid var(--ks-rule); }\n .card.canon button.choose:hover { border-color: var(--ks-text-muted); background: var(--ks-gray-2); }\n button.choose { margin-top: auto; align-self: start; background: var(--ks-instrument); color: var(--ks-instrument-text); border: 0; font-family: var(--ks-font); font-size: .9375rem; font-weight: 600; line-height: 1.35; padding: 11px 24px; border-radius: 6px; cursor: pointer; transition: background .15s; }\n button.choose:hover { background: var(--ks-instrument-raised); }\n /* The round's verbs stay reachable on short viewports: the footer is a\n full-bleed bar stuck to the viewport bottom and the deck scrolls under\n it. Same inset as the content column, so the controls stay aligned. */\n 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: .75rem; align-items: center; flex-wrap: wrap; background: var(--ks-paper); border-top: 1px solid var(--ks-rule); }\n #steer { flex: 1; min-width: 16rem; background: var(--ks-paper-raised); color: var(--ks-text); border: 1px solid var(--ks-edge); border-radius: 6px; padding: .7rem .85rem; font: inherit; caret-color: var(--ks-patina); }\n #steer:focus { outline: 2px solid var(--ks-patina); outline-offset: 2px; }\n /* Build-path toggle: a workflow preference surfaced as a quiet segmented\n control on the headline row, right-aligned opposite the title, its trade stated in\n one line that changes with the selection. The default comes from the\n payload (settings); flipping binds this session only, and the agent\n learns about a code-to-comp flip live. Rendered only when the payload\n offers it, which the agent does only when image generation exists. */\n #build-path { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; flex: none; margin-left: auto; }\n .bp-switch { display: inline-flex; gap: 3px; padding: 3px; background: var(--ks-gray); border: 1px solid var(--ks-rule); border-radius: 6px; box-shadow: var(--ks-track-recess); }\n .bp-note { font-family: var(--ks-font); font-size: .75rem; color: var(--ks-text-muted); max-width: 21rem; line-height: 1.4; }\n .bp-opt { display: inline-flex; align-items: center; gap: 6px; font-family: var(--ks-font); font-size: .75rem; font-weight: 500; padding: 7px 10px; border: 0; border-radius: 3px; color: var(--ks-text-muted); background: transparent; cursor: pointer; transition: color .2s ease, background-color .2s ease; }\n .bp-opt::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: var(--ks-edge); }\n .bp-opt.active::before { background: var(--ks-kinpaku); }\n .bp-opt.active { color: var(--ks-ink); background: var(--ks-paper-raised); box-shadow: var(--ks-cap-lift); }\n .bp-opt:not(.active):hover { color: var(--ks-text); }\n .bp-note { font-family: var(--ks-mono); font-size: .6875rem; letter-spacing: .04em; color: var(--ks-text-faint); max-width: 21rem; line-height: 1.5; }\n /* Flipping to comp starts billed, minutes-long generation, so it asks\n first; flipping back is free and never does. */\n #bp-confirm { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; background: oklch(13% 0 0 / 0.32); opacity: 0; transition: opacity .2s ease; }\n #bp-confirm[hidden] { display: none; }\n #bp-confirm.open { opacity: 1; }\n .bp-confirm-panel { max-width: 26rem; margin: 1rem; background: var(--ks-paper-raised); border: 1px solid var(--ks-rule); border-radius: 8px; padding: 1.4rem 1.5rem 1.3rem; box-shadow: var(--ks-lift); }\n .bp-confirm-panel h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; color: var(--ks-ink); margin-bottom: .55rem; }\n .bp-confirm-panel p { font-size: .875rem; line-height: 1.55; color: var(--ks-text-muted); }\n .bp-confirm-actions { display: flex; gap: .6rem; margin-top: 1.1rem; }\n .bp-confirm-go { background: var(--ks-instrument); color: var(--ks-instrument-text); border: 0; font: inherit; font-weight: 600; padding: 9px 22px; border-radius: 6px; cursor: pointer; }\n .bp-confirm-go:hover { background: var(--ks-instrument-raised); }\n .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; }\n .bp-confirm-stay:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }\n .reroll-btn { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; min-height: 42px; padding: 0 14px; font-family: var(--ks-font); font-size: .8125rem; font-weight: 500; color: var(--ks-ink); background: var(--ks-paper-raised); border: 1px solid var(--ks-edge); border-radius: 6px; box-shadow: var(--ks-cap-lift); cursor: pointer; transition: border-color .2s ease, color .2s ease; }\n .reroll-btn:hover { color: var(--ks-ink); border-color: var(--ks-ink); }\n .reroll-btn svg { width: 15px; height: 15px; }\n .reroll-btn[disabled] { opacity: .4; cursor: default; }\n /* The register steers read quieter than the plain roll: they are exits from\n the current register, not the round's main verbs. */\n #reroll-safer, #reroll-bolder { color: var(--ks-text-muted); min-height: 38px; }\n #reroll-safer:hover, #reroll-bolder:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }\n /* The quiet exit: always available, never argued with, visually subordinate\n to the dealt cards and the re-roll so it reads as the user's own door,\n not a recommendation. */\n #canon { align-self: center; padding: 4px; font-family: var(--ks-font); font-size: .75rem; color: var(--ks-text-muted); background: transparent; border: none; text-decoration: underline; text-underline-offset: .2em; cursor: pointer; }\n #canon:hover { color: var(--ks-ink); }\n #canon[disabled] { opacity: .18; cursor: default; }\n .card.skeleton .media { background: var(--ks-gray); }\n .shimmer { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-gray) 35%, var(--ks-gray-2) 50%, var(--ks-gray) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }\n .card.skeleton .line { height: 11px; border-radius: 4px; background: linear-gradient(100deg, var(--ks-gray) 35%, var(--ks-gray-2) 50%, var(--ks-gray) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }\n .card.skeleton .line.tier { height: 8px; }\n .card.skeleton .line.title { height: 17px; border-radius: 5px; }\n .card.skeleton .line.button { height: 38px; width: 128px; border-radius: 6px; margin-top: auto; }\n .card.skeleton .w40 { width: 40%; } .card.skeleton .w70 { width: 70%; } .card.skeleton .w90 { width: 90%; } .card.skeleton .w80 { width: 80%; } .card.skeleton .w60 { width: 60%; }\n .card.skeleton .body { flex: 1; }\n @keyframes shimmer { from { background-position: 120% 0; } to { background-position: -80% 0; } }\n @media (prefers-reduced-motion: reduce) { .shimmer, .card.skeleton .line { animation: none; } }\n .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-ink); text-align: center; }\n .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-ink); text-align: center; }\n .stall .choose { align-self: center; margin-top: 0; }\n ::selection { background: oklch(49% 0.11 190 / 0.18); color: var(--ks-ink); }\n :focus-visible { outline: 2px solid var(--ks-patina); outline-offset: 3px; }\n #steer::placeholder { color: var(--ks-text-muted); opacity: 1; }\n .body.back-body { scrollbar-color: var(--ks-edge) var(--ks-paper-deep); }\n .reroll-btn:active, .bp-opt:active { box-shadow: var(--ks-cap-press); }\n @media (max-aspect-ratio: 1/1) {\n header { margin-bottom: 1.4rem; }\n h1 { flex: 1; min-width: 0; font-size: 1.5rem; }\n #build-path { flex-basis: 100%; align-items: flex-start; margin: .25rem 0 0; }\n .bp-note { text-align: left; max-width: 100%; }\n footer { position: static; width: auto; margin: 1rem 0 0; padding: .75rem 0 1.2rem; background: transparent; border-top: 0; }\n #steer { flex-basis: 100%; min-width: 0; }\n }\n</style>\n<div id=\"ambient\" aria-hidden=\"true\"></div>\n<div id=\"scrim\" aria-hidden=\"true\"></div>\n<div id=\"lightbox\" hidden><img alt=\"\"></div>\n<template id=\"tpl-expand-chip\">@@2@@</template>\n@@3@@\n<header>\n <div class=\"brand\">\n <svg class=\"brand-logo\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"82.5 82.5 476 87\" role=\"img\" aria-label=\"Impeccable\" fill=\"none\">\n <g transform=\"translate(71.39999999999999 71.39999999999999) scale(4.533333333333333)\" fill=\"var(--ks-kinpaku)\">\n <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\"/>\n <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\"/>\n </g>\n <g transform=\"translate(203.99999999999997,146.69776) scale(0.07072,-0.07072)\"><path d=\"M57 0L57 591L134 591L134 0Z\" transform=\"translate(0,0)\" fill=\"currentColor\"/><path d=\"M57 0L57 591L151 591L266 137L383 591L474 591L474 0L406 0L406 468L293 0L239 0L126 468L126 0Z\" transform=\"translate(341.42370605468756,0)\" fill=\"currentColor\"/><path d=\"M57 0L57 591L248 591Q301 591 331.5 566.5Q362 542 362 492L362 385Q362 350 352.5 319Q343 288 316 269Q289 250 238 250L138 250L138 0ZM138 295L213 295Q249 295 267 319Q285 343 285 395L285 461Q285 506 268.5 526.5Q252 547 227 547L138 547Z\" transform=\"translate(1022.9385986328125,0)\" fill=\"currentColor\"/><path d=\"M57 0L57 591L317 591L317 536L138 536L138 338L283 338L283 278L138 278L138 54L317 54L317 0Z\" transform=\"translate(1561.847412109375,0)\" fill=\"currentColor\"/><path d=\"M203 -12Q158 -12 123 -1.5Q88 9 68 42Q48 75 48 141L48 467Q48 522 68 550.5Q88 579 123.5 589Q159 599 204 599Q250 599 283.5 588.5Q317 578 336.5 550Q356 522 356 467L356 359L279 359L279 469Q279 505 269 522.5Q259 540 242 545.5Q225 551 203 551Q181 551 164 545.5Q147 540 137.5 522.5Q128 505 128 469L128 117Q128 81 137.5 63.5Q147 46 164 40.5Q181 35 203 35Q226 35 242.5 40.5Q259 46 269 63.5Q279 81 279 117L279 213L356 213L356 141Q356 74 336.5 41.5Q317 9 283 -1.5Q249 -12 203 -12Z\" transform=\"translate(2051.968994140625,0)\" fill=\"currentColor\"/><path d=\"M203 -12Q158 -12 123 -1.5Q88 9 68 42Q48 75 48 141L48 467Q48 522 68 550.5Q88 579 123.5 589Q159 599 204 599Q250 599 283.5 588.5Q317 578 336.5 550Q356 522 356 467L356 359L279 359L279 469Q279 505 269 522.5Q259 540 242 545.5Q225 551 203 551Q181 551 164 545.5Q147 540 137.5 522.5Q128 505 128 469L128 117Q128 81 137.5 63.5Q147 46 164 40.5Q181 35 203 35Q226 35 242.5 40.5Q259 46 269 63.5Q279 81 279 117L279 213L356 213L356 141Q356 74 336.5 41.5Q317 9 283 -1.5Q249 -12 203 -12Z\" transform=\"translate(2600.60546875,0)\" fill=\"currentColor\"/><path d=\"M13 0L137 591L241 591L364 0L279 0L251 130L121 130L95 0ZM124 170L249 170L187 507Z\" transform=\"translate(3149.241943359375,0)\" fill=\"currentColor\"/><path d=\"M57 0L57 591L247 591Q298 591 329 566.5Q360 542 360 492L360 422Q360 394 352 372.5Q344 351 330 338Q316 325 297.5 320Q279 315 265 314L265 315Q284 315 302 310.5Q320 306 333 294Q349 280 358 258Q367 236 367 206L367 122Q367 72 350.5 45.5Q334 19 305 9.5Q276 0 236 0ZM135 43L221 43Q242 43 257 49Q272 55 280.5 73.5Q289 92 289 127L289 205Q289 251 267.5 266Q246 281 213 281L135 281ZM135 331L211 331Q247 331 266 351.5Q285 372 285 413L285 479Q285 514 267.5 530.5Q250 547 225 547L135 547Z\" transform=\"translate(3676.1197509765625,0)\" fill=\"currentColor\"/><path d=\"M57 0L57 591L134 591L134 54L290 54L290 0Z\" transform=\"translate(4236.2109375,0)\" fill=\"currentColor\"/><path d=\"M57 0L57 591L317 591L317 536L138 536L138 338L283 338L283 278L138 278L138 54L317 54L317 0Z\" transform=\"translate(4683.574462890625,0)\" fill=\"currentColor\"/></g>\n</svg>\n </div>\n</header>\n<main>\n <div class=\"stage\">\n <div class=\"headline\">\n <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>\n <h1>@@4@@</h1>\n @@5@@\n </div>\n @@6@@\n <div class=\"deck-shell\">\n <div class=\"grid\">@@7@@</div>\n <div class=\"fade fade-prev\" aria-hidden=\"true\"></div>\n <div class=\"fade fade-next\" aria-hidden=\"true\"></div>\n <button class=\"nav prev\" hidden aria-label=\"Previous card\"><svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M14.5 5 8 12l6.5 7\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg></button>\n <button class=\"nav next\" hidden aria-label=\"Next card\"><svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M9.5 5 16 12l-6.5 7\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg></button>\n </div>\n </div>\n</main>\n<footer>\n @@8@@\n @@9@@\n @@10@@\n</footer>\n<script>\n const steer = () => document.getElementById('steer')?.value || '';\n // A followup round's pick keeps the tab: the next round arrives via\n // --update, so the page shows the loading hand instead of goodbye. Detached\n // mode only, and the page must agree with the server: a blocking server\n // exits on any pick and has no update channel, so a followup payload there\n // still gets the goodbye screen, never a loading hand nothing will resolve.\n const FOLLOWUP = @@11@@;\n const KEY = @@15@@;\n const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';\n const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };\n @@12@@\n const beatTimer = setInterval(beat, 5000);\n // A dead server must fail loudly: awaiting a rejected fetch here used to\n // swallow the click and never print the confirmation, so the user believed\n // a choice had landed that no one would ever collect.\n async function answer(optionId) {\n // Quiet at the click: a re-roll or canon posted while this pick's POST\n // is in flight would overwrite the answer being collected.\n document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));\n try {\n await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });\n } catch {\n 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>';\n return;\n }\n if (FOLLOWUP) { await awaitNextRound(true); return; }\n 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>';\n }\n document.querySelectorAll('button.choose').forEach(b => b.addEventListener('click', () => answer(b.dataset.id)));\n document.querySelectorAll('.flip').forEach(b => b.addEventListener('click', (e) => {\n e.stopPropagation();\n b.closest('.card').classList.toggle('flipped');\n }));\n\n // Raise cycler: click (or Enter) advances to the next donation.\n document.querySelectorAll('.raises-cycle').forEach(cycle => {\n const raises = [...cycle.querySelectorAll('.raise')];\n const count = cycle.querySelector('[data-raises-count]');\n let at = 0;\n const live = cycle.querySelector('.sr-live');\n const show = (announce) => {\n raises.forEach((raise, i) => raise.classList.toggle('active', i === at));\n if (count) count.textContent = (at + 1) + '/' + raises.length;\n // Screen readers hear the raise they just advanced to; the initial\n // render stays quiet so page load does not narrate every card.\n if (announce && live) live.textContent = 'Improvement ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');\n };\n show(false);\n const advance = (e) => { e.stopPropagation(); at = (at + 1) % raises.length; show(true); };\n cycle.addEventListener('click', advance);\n cycle.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); advance(e); } });\n });\n\n // Deal from the stack: cards begin piled at the grid's center, blurred,\n // then travel to their seats with a stagger.\n const cards = [...document.querySelectorAll('.card')];\n // The deal is decoration: a hidden tab throttles rAF, so never let the\n // animation hold the cards at opacity 0. Skip it when hidden, and force\n // the final state after a beat no matter what the animation did.\n setTimeout(() => cards.forEach(c => { c.style.opacity = ''; c.style.transform = ''; c.style.filter = ''; c.style.transition = ''; c.style.zIndex = ''; }), 1600);\n if (!matchMedia('(prefers-reduced-motion: reduce)').matches && cards.length && !document.hidden) {\n const grid = document.querySelector('.grid').getBoundingClientRect();\n const cx = grid.left + grid.width / 2, cy = grid.top + grid.height / 2;\n cards.forEach((card, i) => {\n const r = card.getBoundingClientRect();\n const dx = cx - (r.left + r.width / 2), dy = cy - (r.top + r.height / 2);\n card.style.transition = 'none';\n card.style.transform = 'translate(' + dx + 'px,' + (dy + 14) + 'px) rotate(' + (i % 2 ? 5 : -4) + 'deg) scale(.9)';\n card.style.opacity = '0';\n card.style.filter = 'blur(10px)';\n card.style.zIndex = String(cards.length - i);\n });\n requestAnimationFrame(() => requestAnimationFrame(() => {\n cards.forEach((card, i) => {\n const delay = i * 110;\n card.style.transition = 'transform .7s cubic-bezier(.16,1,.3,1) ' + delay + 'ms, opacity .45s ease ' + delay + 'ms, filter .55s ease ' + delay + 'ms';\n card.style.transform = ''; card.style.opacity = '1'; card.style.filter = '';\n card.addEventListener('transitionend', function done(e) {\n if (e.propertyName !== 'transform') return;\n card.style.transition = ''; card.style.opacity = ''; card.style.zIndex = '';\n card.removeEventListener('transitionend', done);\n });\n });\n }));\n }\n\n // Comps stream in after the deal: poll each slot until the file lands,\n // then swap the shimmer for the image. Generation is genuinely slow and a\n // sequential batch puts the last card many minutes out, so patience is the\n // default: a slot only shows its inspiration as a stand-in when it has\n // waited four minutes AND nothing has landed anywhere for four minutes, the\n // stand-in is labeled as such, and polling continues so the real comp\n // still swaps in whenever it arrives. Progress anywhere resets patience.\n const landTracker = { last: Date.now() };\n const pollComp = (m) => {\n const url = m.dataset.comp;\n // A converted slot is the SAME node across flip cycles, so a probe from an\n // earlier cycle can still be in flight when the next one starts. Without a\n // generation stamp its late onload settles the new slot: shimmer stripped,\n // pending state cleared, comp still hidden, live poll stopped.\n const generation = String(Number(m.dataset.pollGen || 0));\n const current = () => String(Number(m.dataset.pollGen || 0)) === generation;\n const img = m.querySelector('img.comp');\n const note = m.querySelector('.comp-note');\n const started = Date.now();\n // A live elapsed count is the difference between \"working\" and \"frozen\".\n const tick = setInterval(() => { if (note) note.textContent = 'rendering · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);\n const settle = () => { clearInterval(tick); m.classList.remove('comp-pending', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };\n const fallback = () => {\n const pip = m.querySelector('.pip img');\n if (pip) {\n if (m.classList.contains('stand-in')) return false;\n img.src = pip.getAttribute('src'); img.hidden = false;\n m.classList.add('stand-in');\n m.querySelector('.shimmer')?.remove();\n clearInterval(tick);\n const label = document.createElement('p');\n label.className = 'stand-in-label';\n label.textContent = 'inspiration · comp pending';\n m.appendChild(label);\n return false;\n }\n\n // No comp and no inspiration is the text-only card the payload would\n // have rendered without a comp declaration. Bring the complete read\n // forward before removing the now-unreachable back face.\n const card = m.closest('.card');\n const front = card?.querySelector('.face.front');\n const body = front?.querySelector('.body');\n const back = card?.querySelector('.face.back');\n const textOnlyFacts = m.querySelector('template.text-only-facts');\n const choose = body?.querySelector(':scope > button.choose');\n if (body && textOnlyFacts && choose) {\n const plainDetail = body.querySelector(':scope > .detail:not(.more)');\n [...body.children].filter((el) => el.classList.contains('fact') || el.matches('.detail.more')).forEach((el) => el.remove());\n choose.before(textOnlyFacts.content.cloneNode(true));\n if (plainDetail) choose.before(plainDetail);\n }\n card?.classList.remove('flipped');\n front?.classList.add('text-only');\n back?.remove();\n settle();\n m.remove();\n return true;\n };\n const tryLoad = () => {\n // A slot the user flipped back out of either leaves the DOM or, when it\n // was an inspiration face converted in place, stays and loses its\n // pending state. Either way its loop is done.\n if (!m.isConnected || !m.classList.contains('comp-pending') || !current()) { clearInterval(tick); return; }\n const probe = new Image();\n probe.onload = () => {\n // A stale generation also ends this run's clock. tryLoad clears it on\n // re-entry, and an in-flight probe that finishes stale schedules no\n // re-entry, so returning without clearing ran the interval for the rest\n // of the page's life.\n if (!current()) { clearInterval(tick); return; }\n landTracker.last = Date.now();\n const target = m.querySelector('img.comp') || img;\n target.src = probe.src;\n target.hidden = false;\n settle();\n };\n probe.onerror = () => {\n if (!current()) { clearInterval(tick); return; }\n const quiet = Date.now() - landTracker.last > 240000;\n if (Date.now() - started > 240000 && quiet && fallback()) return;\n setTimeout(tryLoad, m.classList.contains('stand-in') ? 5000 : 2500);\n };\n probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();\n };\n tryLoad();\n };\n document.querySelectorAll('.media.comp-pending').forEach(pollComp);\n\n // Build-path toggle: the default is the round's recorded preference and\n // flipping binds this session only. Flipping code to comp swaps every\n // reserve slot (data-comp-slot) to its shimmer and tells the server, so\n // the waiting agent starts generating; flipping back is free: pending\n // slots return to their wireframes, a comp that already landed stays.\n const bp = document.getElementById('build-path');\n if (bp) {\n const notes = {\n comp: 'An image sets the bar first and the build must match it. Bolder composition; comps render before code.',\n code: 'Code builds directly; the ambition is written into the contract and audited at the finish. Leaner, faster.',\n };\n const noteEl = bp.querySelector('[data-bp-note]');\n let current = bp.dataset.default;\n const set = (value) => {\n current = value;\n bp.querySelectorAll('.bp-opt').forEach(b => {\n const on = b.dataset.bp === value;\n b.classList.toggle('active', on);\n b.setAttribute('aria-checked', String(on));\n });\n if (noteEl) noteEl.textContent = notes[value];\n };\n set(current);\n const INSPO_TITLE = 'Inspiration: the world this direction draws from. Your page will not look like this image.';\n // Every media slot carries the expand affordance. A slot built here after\n // the deal used to ship without one, so a comp the user waited minutes for\n // could not be opened.\n const ensureChips = (m) => {\n if (m.querySelector('.chips')) return;\n const tpl = document.getElementById('tpl-expand-chip');\n if (!tpl) return;\n const chips = document.createElement('div');\n chips.className = 'chips';\n chips.appendChild(tpl.content.cloneNode(true));\n m.appendChild(chips);\n };\n const shimmerHtml = '<div class=\"shimmer\"><span class=\"comp-note\">rendering&hellip;</span></div><img class=\"comp\" alt=\"\" hidden>';\n const enterComp = () => {\n document.querySelectorAll('.card[data-comp-slot]').forEach(card => {\n const front = card.querySelector('.face.front');\n if (!front || front.querySelector('.media.comp-pending') || front.querySelector('.media img.comp:not([hidden])')) return;\n // On a code-led card the inspiration IS the face. Comp-first demotes it\n // to the corner, so convert that slot in place rather than inserting a\n // second one: two stacked images say the catalog art and the comp are\n // peers, and the whole point of the corner is that they are not.\n const inspo = front.querySelector('.media:not(.wire):not(.comp-pending)');\n let m;\n if (inspo) {\n m = inspo;\n m.dataset.compRestore = 'inspiration';\n m.dataset.comp = card.dataset.compSlot;\n m.classList.add('comp-pending');\n m.removeAttribute('title');\n m.querySelector('.media-label')?.remove();\n const art = m.querySelector(':scope > img');\n if (art) {\n const pip = document.createElement('figure');\n pip.className = 'pip';\n pip.title = INSPO_TITLE;\n const cap = document.createElement('figcaption');\n cap.textContent = 'inspiration';\n pip.append(art, cap);\n m.appendChild(pip);\n }\n m.insertAdjacentHTML('afterbegin', shimmerHtml);\n } else {\n m = document.createElement('div');\n m.className = 'media comp-pending';\n m.dataset.comp = card.dataset.compSlot;\n m.innerHTML = shimmerHtml;\n const wireEl = front.querySelector('.media.wire');\n if (wireEl) { wireEl.hidden = true; front.insertBefore(m, wireEl); }\n else { front.classList.remove('text-only'); front.insertBefore(m, front.querySelector('.body')); }\n }\n ensureChips(m);\n pollComp(m);\n });\n };\n const exitComp = () => {\n document.querySelectorAll('.card[data-comp-slot]').forEach(card => {\n const front = card.querySelector('.face.front');\n const pending = front?.querySelector('.media.comp-pending');\n if (!pending) return; // landed comps stay; they exist either way\n // A converted slot is restored, not removed: the inspiration goes back\n // to being the face, so flipping back leaves the card as it was dealt.\n if (pending.dataset.compRestore === 'inspiration') {\n // Everything the pending state added has to leave, presentation\n // included: a slot that reached stand-in kept its \"inspiration comp\n // pending\" label beside a fresh one, and a slot whose art had failed\n // came back still marked unavailable.\n pending.classList.remove('comp-pending', 'stand-in');\n delete pending.dataset.compRestore;\n delete pending.dataset.comp;\n pending.dataset.pollGen = String(Number(pending.dataset.pollGen || 0) + 1);\n pending.querySelector('.shimmer')?.remove();\n pending.querySelector('img.comp')?.remove();\n pending.querySelector('.stand-in-label')?.remove();\n pending.querySelectorAll('.media-label').forEach((el) => el.remove());\n const pip = pending.querySelector('.pip');\n const art = pip?.querySelector('img');\n if (art) pending.insertBefore(art, pending.firstChild);\n pip?.remove();\n // No art means the image failed to load before the flip, so hand the\n // slot back to the same honest treatment rather than calling it art.\n const label = document.createElement('p');\n label.className = 'media-label';\n label.textContent = art ? 'inspiration' : 'artwork unavailable';\n pending.insertBefore(label, pending.querySelector('.chips'));\n if (art) pending.title = INSPO_TITLE; else pending.classList.add('unavailable');\n return;\n }\n pending.remove();\n const wireEl = front.querySelector('.media.wire');\n if (wireEl) wireEl.hidden = false;\n else if (!front.querySelector('.media')) front.classList.add('text-only');\n });\n };\n const apply = (value) => {\n set(value);\n fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });\n if (value === 'comp') enterComp(); else exitComp();\n };\n // Flipping to comp starts real generation, so it confirms first; the\n // flip back is free and applies immediately.\n const confirm = document.getElementById('bp-confirm');\n const closeConfirm = () => { confirm.classList.remove('open'); confirm.hidden = true; };\n confirm.querySelector('[data-confirm]').addEventListener('click', () => { closeConfirm(); apply('comp'); });\n confirm.querySelector('[data-cancel]').addEventListener('click', closeConfirm);\n confirm.addEventListener('click', (e) => { if (e.target === confirm) closeConfirm(); });\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !confirm.hidden) closeConfirm(); });\n bp.querySelectorAll('.bp-opt').forEach(b => b.addEventListener('click', () => {\n const value = b.dataset.bp;\n if (value === current) return;\n if (value === 'comp') {\n confirm.hidden = false;\n requestAnimationFrame(() => confirm.classList.add('open'));\n return;\n }\n apply(value);\n }));\n }\n\n // A declared image that never loads (missing catalog asset, offline shell)\n // must not sit as a dark void: the slot collapses to the card's own\n // palette, labeled honestly, and the card competes on its facts. Sketch\n // slots are excluded; their polling owns the wait.\n const artFailed = (img) => {\n const m = img.closest('.media');\n if (!m || m.classList.contains('comp-pending') || m.classList.contains('unavailable')) return;\n m.classList.add('unavailable');\n const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);\n 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(', ') + ')';\n m.querySelector('.media-label')?.remove();\n m.querySelector('.chip.expand')?.remove();\n m.removeAttribute('title');\n img.remove();\n const label = document.createElement('p');\n label.className = 'media-label';\n label.textContent = 'artwork unavailable';\n m.appendChild(label);\n };\n document.querySelectorAll('.media:not(.comp-pending) > img').forEach(img => {\n if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);\n else img.addEventListener('error', () => artFailed(img), { once: true });\n });\n // A broken inspiration PIP or thumb just leaves; nothing depends on it.\n document.querySelectorAll('.pip img, .inspo img').forEach(img => {\n const gone = () => img.closest('.pip, .inspo')?.remove();\n if (img.complete && img.naturalWidth === 0) gone();\n else img.addEventListener('error', gone, { once: true });\n });\n\n // Every zoom target resolves in ONE delegated listener, in priority order.\n // Delegation is what lets a slot built by a build-path flip work at all, and\n // a single listener is what keeps the targets from fighting: stopPropagation\n // ends bubbling, not other listeners on the same target, so split across two\n // handlers a click on the corner inspiration opened the inspiration and then\n // the comp overwrote it in the lightbox. Per-element handlers elsewhere (the\n // flip chip, the raise cycler) still stop bubbling before the event lands\n // here, so they keep their own behavior.\n document.addEventListener('click', (e) => {\n const target = e.target;\n if (!target || !target.closest) return;\n // The corner inspiration wins over the slot it sits inside.\n const pip = target.closest('.pip, .inspo');\n if (pip) {\n const art = pip.querySelector('img');\n if (art) openLightbox(art);\n return;\n }\n const chip = target.closest('.chip');\n if (chip) {\n // Only expand zooms. Any other chip owns its click and must not fall\n // through to the media underneath it.\n if (!chip.classList.contains('expand')) return;\n const card = chip.closest('.card');\n const face = card && card.classList.contains('flipped') ? '.face.back' : '.face.front';\n const shown = card && card.querySelector(face + ' .media img:not([hidden])');\n if (shown && shown.getAttribute('src')) openLightbox(shown);\n return;\n }\n // The whole image is the zoom target, not just the chip; the chip stays as\n // the visible affordance.\n const media = target.closest('.media');\n if (!media) return;\n const art = media.querySelector(':scope > img:not([hidden])');\n if (art && art.getAttribute('src')) openLightbox(art);\n });\n\n // Deck paging: arrows appear only when the deck overflows its axis, page\n // one card at a time, and follow the aspect-ratio flip between row and column.\n const deck = document.querySelector('.grid');\n const prevBtn = document.querySelector('.nav.prev');\n const nextBtn = document.querySelector('.nav.next');\n const vertical = () => matchMedia('(max-aspect-ratio: 1/1)').matches;\n function updateNav() {\n if (!deck || !prevBtn) return;\n const shell = deck.closest('.deck-shell');\n const v = vertical();\n const overflow = v ? deck.scrollHeight > deck.clientHeight + 4 : deck.scrollWidth > deck.clientWidth + 4;\n prevBtn.hidden = nextBtn.hidden = !overflow;\n const pos = v ? deck.scrollTop : deck.scrollLeft;\n const max = v ? deck.scrollHeight - deck.clientHeight : deck.scrollWidth - deck.clientWidth;\n const canPrev = overflow && pos > 2;\n const canNext = overflow && pos < max - 2;\n prevBtn.toggleAttribute('disabled', !canPrev);\n nextBtn.toggleAttribute('disabled', !canNext);\n shell?.classList.toggle('can-prev', canPrev);\n shell?.classList.toggle('can-next', canNext);\n }\n function pageDeck(dir) {\n const card = deck.querySelector('.card');\n if (!card) return;\n const r = card.getBoundingClientRect();\n const step = (vertical() ? r.height : r.width) + 26;\n deck.scrollBy(vertical() ? { top: dir * step, behavior: 'smooth' } : { left: dir * step, behavior: 'smooth' });\n }\n prevBtn?.addEventListener('click', () => pageDeck(-1));\n nextBtn?.addEventListener('click', () => pageDeck(1));\n deck?.addEventListener('scroll', updateNav, { passive: true });\n addEventListener('resize', updateNav);\n updateNav();\n\n // Ambient: the hovered card's visible art bleeds into the page ground.\n const ambient = document.getElementById('ambient');\n document.querySelectorAll('.card').forEach(card => {\n card.addEventListener('mouseenter', () => {\n const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img') || card.querySelector('.face.front .inspo img');\n if (!art || !art.getAttribute('src')) return;\n ambient.style.backgroundImage = 'url(\"' + art.getAttribute('src') + '\")'; ambient.style.opacity = '1';\n });\n card.addEventListener('mouseleave', () => { ambient.style.opacity = '0'; });\n });\n\n // Expand: lightbox for whichever face is showing.\n const lightbox = document.getElementById('lightbox');\n const lightboxImg = lightbox.querySelector('img');\n // Declared, not assigned to a const, so the delegated handlers above can call\n // it wherever they sit in this file.\n function openLightbox(img) {\n lightboxImg.src = img.getAttribute('src');\n lightbox.hidden = false;\n requestAnimationFrame(() => lightbox.classList.add('open'));\n }\n // Portrait art (native / mobile-first surfaces): the slot takes the\n // image's own ratio so nothing crops, and the whole deck narrows so\n // portrait cards sit side by side. Load events don't bubble; capture.\n document.addEventListener('load', (e) => {\n const img = e.target;\n if (!(img instanceof HTMLImageElement) || !img.matches('.media > img')) return;\n if (img.naturalHeight > img.naturalWidth * 1.05) {\n const m = img.closest('.media');\n m.classList.add('portrait');\n m.style.aspectRatio = img.naturalWidth + ' / ' + img.naturalHeight;\n document.querySelector('.grid')?.classList.add('portrait-media');\n }\n }, true);\n const closeLightbox = () => { lightbox.classList.remove('open'); setTimeout(() => { lightbox.hidden = true; }, 250); };\n lightbox.addEventListener('click', closeLightbox);\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });\n document.getElementById('canon')?.addEventListener('click', () => answer('canon'));\n const dealAgain = async (register) => {\n // Quiet at the click, not after the fly-out: the POST round-trip plus\n // the 700ms animation was a window where a second click posted another\n // re-roll and renewed the delivery deadline.\n document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));\n try {\n await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });\n } catch {\n 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>';\n return;\n }\n await awaitNextRound(true);\n };\n async function awaitNextRound(animate, budgetMs = @@13@@) {\n const grid = document.querySelector('.grid');\n let poll;\n let misses = 0;\n const shuffleStart = Date.now();\n const stall = (message) => {\n clearInterval(poll);\n // A stalled page is an abandoned flow: keep heartbeating and the\n // daemon never reaches its idle grace, so --wait spins on WAITING\n // forever. Go silent and let the server reclaim itself. Reload must\n // not undo that silence: an unconditional reload re-serves the same\n // unresolved round and its fresh page beats again, so check for a\n // delivered hand first and only reload when one exists. The re-roll\n // buttons and the canon exit go too: a stalled page served already\n // expired never disabled them, a re-roll would renew the deadline the\n // stall just enforced, and a canon pick would overwrite a re-roll\n // --wait already collected, closing the table under the agent.\n clearInterval(beatTimer);\n document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));\n // Silence is for heartbeats only: a hand delivered after the deadline\n // must still land without a click, so a beat-free watch keeps checking\n // and reloads into it. /next-status never beats, so the daemon's idle\n // grace still reclaims a flow nobody resumes.\n const watch = setInterval(async () => {\n try { if ((await (await fetch('/next-status')).json()).ready) { clearInterval(watch); location.reload(); } } catch { /* server gone; the screen already says so */ }\n }, 1500);\n grid.innerHTML = '<div class=\"stall\"><p>' + message + '</p><button type=\"button\" class=\"choose\">Reload</button></div>';\n grid.querySelector('.stall .choose').addEventListener('click', async () => {\n try {\n if ((await (await fetch('/next-status')).json()).ready) { location.reload(); return; }\n grid.querySelector('.stall p').textContent = 'Still nothing to deal. Check the agent session, or answer in the chat instead.';\n } catch {\n grid.querySelector('.stall p').textContent = 'The question server went away. Ask the agent to restart it, or answer in the chat instead.';\n }\n });\n };\n // A refresh that lands after the delivery deadline has nothing left to\n // wait for: stall before the heartbeat timer's first tick can fire, so\n // the served page stays silent.\n if (budgetMs <= 0) { stall('The next hand never arrived. Check the agent session, then reload.'); return; }\n const cardsNow = [...grid.querySelectorAll('.card')];\n if (animate && !matchMedia('(prefers-reduced-motion: reduce)').matches) {\n const g = grid.getBoundingClientRect();\n const cx = g.left + g.width / 2, cy = g.top + g.height / 2;\n cardsNow.forEach((card, i) => {\n const r = card.getBoundingClientRect();\n 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';\n card.style.transform = 'translate(' + (cx - (r.left + r.width / 2)) + 'px,' + (cy - (r.top + r.height / 2) + 14) + 'px) rotate(' + (i % 2 ? 6 : -5) + 'deg) scale(.9)';\n card.style.opacity = '0';\n card.style.filter = 'blur(8px)';\n });\n await new Promise(r => setTimeout(r, 700));\n }\n const cardHeight = cardsNow[0] ? cardsNow[0].getBoundingClientRect().height : 0;\n 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('');\n // Canon goes quiet with the re-roll buttons: a pick posted mid-wait can\n // never be collected once --wait has the re-roll, only close the table.\n document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));\n // The wait must be able to end: a dead server rejects every tick and a\n // round nobody delivers stays ready:false forever, and both used to spin\n // the skeletons indefinitely. Distinguish them, say so, and offer a way\n // out. The delivery deadline is the server's own idle grace, so the page\n // never gives up on a server that would still accept the hand.\n poll = setInterval(async () => {\n try {\n const status = await (await fetch('/next-status')).json();\n misses = 0;\n if (status.ready) { clearInterval(poll); location.reload(); }\n else if (Date.now() - shuffleStart > budgetMs) stall('The next hand never arrived. Check the agent session, then reload.');\n } catch {\n misses += 1;\n if (misses >= 8) stall('The question server went away. Ask the agent to restart it, or answer in the chat instead.');\n }\n }, 1200);\n }\n document.getElementById('reroll')?.addEventListener('click', () => dealAgain());\n document.getElementById('reroll-safer')?.addEventListener('click', () => dealAgain('safer'));\n document.getElementById('reroll-bolder')?.addEventListener('click', () => dealAgain('bolder'));\n // A native refresh must not resurrect an answered round: while the server\n // holds a collected re-roll or followup pick with no replacement delivered,\n // it serves the page in waiting mode and the refresh re-enters the same\n // bounded wait, with only the time the original deadline has left, instead\n // of showing dead cards whose heartbeat props the daemon forever.\n @@14@@\n</script>";
/// The JS expressions, in order, for reference.
pub const PAGE_EXPRS: &[&str] = &[
"esc(payload.title || 'impeccable · decision')",
"expandChip",
"buildPath?.toggle ? `<div id=\"bp-confirm\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"bp-confirm-title\" hidden> <div class=\"bp-confirm-panel\"> <h2 id=\"bp-confirm-title\">Flip to comp-first?</h2> <",
"esc(payload.title || 'Choose a direction')",
"buildPath?.toggle ? `<div id=\"build-path\" data-default=\"${buildPath.value}\"> <div class=\"bp-switch\" role=\"radiogroup\" aria-label=\"Build path\"> <button type=\"button\" class=\"bp-opt\" data-bp=\"comp\" role=",
"payload.question ? `<p class=\"question\">${esc(payload.question)}</p>` : ''",
"cards",
"payload.steer ? '<input id=\"steer\" placeholder=\"Optional steer: what should be different or kept?\">' : ''",
"(() => { if (!payload.reroll) return ''; const die = '<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"4\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\"/>",
"payload.canon && !payload.canonCard ? '<button id=\"canon\" title=\"Skip the roll: build the page this category ships, executed impeccably\">Play it straight</button>' : ''",
"payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'",
"waiting && waitBudgetMs <= 0 ? '' : 'beat();'",
"idleGraceMs",
"waiting ? `awaitNextRound(false, ${waitBudgetMs});` : ''",
];