mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 16:46:31 +03:00
The engine no longer lives in a separate repo. `crates/` is a snapshot of the
open crates (foundation, core, common, context, live, hook, skills, comp,
comp-verbs, html, browser, detect, cli) plus `Cargo.lock`, taken as a git
archive of the engine repo at the commit that finished the boundary split.
None of that repo's history comes with it, and none of it should: the closed
half stays private.
The closed half is the rule engine. It ships as a prebuilt native archive per
target, `libimpeccable_detector.a`, published as a `detector-v<X>` GitHub
Release on this repo. `crates/core/build.rs` resolves and links it three ways:
`IMPECCABLE_DETECTOR_LIB=<dir>` for a local detector build, else the
`~/.impeccable/detector/<version>/<target>/` cache, else a download verified
against its `.sha256` sidecar. `crates/core` is a thin shim over a three-symbol
C ABI; nothing above it knows the boundary exists.
What changed versus the engine repo copy:
- Every crate manifest moves from `license-file.workspace` to
`license.workspace` (this workspace declares Apache-2.0), and the workspace
gains the `postcard` dependency the boundary encoding needs.
- The launcher contract test reads `skill/scripts/impeccable{,.cmd}` instead of
a sibling `launcher/` dir, and `engine_binary` downloads from
`github.com/pbakaus/impeccable/releases/download/engine-v<version>/` instead
of the retired dist repo. No oracle golden carried the old URL, so no
re-recording was owed.
- The tests that hunted for a public repo through `IMPECCABLE_PUBLIC_REPO`,
`../impeccable-second` or a hardcoded home directory now resolve the root as
`CARGO_MANIFEST_DIR/../..`, because they are in it. The env var stays as an
override for an out-of-tree checkout.
- The in-page bundle (`detect-antipatterns-browser.js`, 2 MB of generated wasm
glue) is no longer tracked. `crates/core/build.rs` resolves it beside the
archive, hands the path to `impeccable_core::browser::IN_PAGE_BUNDLE_JS`, and
live mode serves that. `scripts/check-detector-release.mjs` now requires it
and its `.sha256` in a detector release.
- The live crate embeds `skill/scripts/live-browser*.js` and
`modern-screenshot.umd.js` directly rather than through vendored copies, so
the binary and the installed skill cannot drift.
- `crates/browser/assets/` (an unused second copy of the bundle) is gone.
- `tests/lib/engine-bin.mjs` also accepts `target/release/impeccable`, so a
plain `cargo build --release -p impeccable` is enough to run `bun run test`.
Verified with the archive from a local detector build: `cargo test --workspace`
267 pass, oracle 795 pass / 0 fail / 0 missing, `bun run build` clean, the
default suite green, and the launcher's `engine-probe` handshake answering
through `skill/scripts/impeccable`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
23 lines
64 KiB
Rust
23 lines
64 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.
|
|
|
|
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<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Albert+Sans:wght@400;500;600&family=Alumni+Sans:wght@100;400&display=swap\" rel=\"stylesheet\">\n<style>\n /* Neo kinpaku tokens, mirrored from impeccable.style kinpaku-tokens.css */\n :root {\n color-scheme: dark;\n --ks-kinpaku: oklch(84% 0.19 80.46);\n --ks-kinpaku-pale: oklch(86% 0.07 84);\n --ks-kinpaku-rich: oklch(77% 0.13 82);\n --ks-kinpaku-deep: oklch(61% 0.085 78);\n --ks-dark-ink: oklch(14% 0.018 95);\n --ks-patina: oklch(70% 0.12 188);\n --ks-lacquer: oklch(7% 0.006 95);\n --ks-lacquer-raised: oklch(11% 0.006 95);\n --ks-graphite: oklch(15% 0.008 95);\n --ks-graphite-2: oklch(19% 0.008 95);\n --ks-champagne: oklch(91% 0 0);\n --ks-text: oklch(88% 0 0);\n --ks-text-muted: oklch(72% 0 0);\n --ks-text-faint: oklch(62% 0 0);\n --ks-rule: oklch(78% 0 0 / 0.16);\n --ks-font-display: \"Alumni Sans\", \"Albert Sans\", Arial, sans-serif;\n --ks-font: \"Albert Sans\", \"Avenir Next\", \"Helvetica Neue\", Arial, system-ui, sans-serif;\n --ks-mono: \"SFMono-Regular\", \"Roboto Mono\", \"JetBrains Mono\", Consolas, monospace;\n /* One inset shared by the content column, the deck's snap padding, and\n the sticky footer, so all three align on the same 90rem column. */\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-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; }\n #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; }\n #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; }\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(4% 0.004 95 / 0.93); 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: 0 30px 80px oklch(0% 0 0 / 0.6); }\n header { width: 100%; max-width: 90rem; margin: 0 auto; }\n .brand { display: flex; align-items: center; gap: .55rem; color: var(--ks-kinpaku); }\n .brand svg { width: 22px; height: 22px; }\n .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); }\n .headline { display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; }\n .headline-die { flex: none; width: 34px; height: 34px; color: var(--ks-kinpaku); }\n 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); }\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: clamp(2rem, 3.4vw, 2.9rem); }\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: 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; }\n .nav:hover { border-color: var(--ks-kinpaku-deep); color: var(--ks-kinpaku-pale); }\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-lacquer), transparent); }\n .fade-next { right: 0; top: 0; bottom: 0; width: 88px; background: linear-gradient(270deg, var(--ks-lacquer), 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; border-color: var(--ks-kinpaku-deep); background: oklch(7% 0.006 95 / 0.88); font-family: var(--ks-mono); font-size: .62rem; letter-spacing: .2em; text-transform: uppercase; }\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-lacquer), transparent); }\n .fade-next { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: 72px; background: linear-gradient(0deg, var(--ks-lacquer), 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-lacquer-raised); border: 1px solid var(--ks-rule); border-radius: 10px; box-shadow: 0 18px 40px oklch(0% 0 0 / 0.35); 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); box-shadow: 0 0 0 1px var(--ks-kinpaku), 0 18px 40px oklch(0% 0 0 / 0.45); }\n .card:hover .face { border-color: var(--ks-kinpaku-deep); }\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 10px; background: var(--ks-kinpaku); color: var(--ks-dark-ink); font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .24em; text-transform: uppercase; border-radius: 4px; }\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-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }\n .media > img:not([hidden]) { cursor: zoom-in; }\n .face.back { background: var(--ks-lacquer-raised); }\n .back-bar { margin-top: auto; background: var(--ks-lacquer-raised); }\n .hero-blank { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 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: 4px 9px; font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .18em; text-transform: uppercase; color: var(--ks-text); background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 5px; cursor: pointer; backdrop-filter: blur(4px); transition: color .2s, border-color .2s; }\n .chip:hover { color: var(--ks-kinpaku); border-color: var(--ks-kinpaku-deep); }\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: .625rem; letter-spacing: .24em; text-transform: uppercase; color: var(--ks-text-faint); }\n h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; line-height: 1.35; color: var(--ks-champagne); }\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: .6rem; 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: .6rem; 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-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); }\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: .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); }\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: .66rem; 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: .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); }\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-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 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: .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); }\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-graphite); }\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-graphite-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-lacquer); border-bottom: 1px solid var(--ks-rule); }\n .wire-field { position: absolute; inset: 12px 12px 26px; }\n .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; }\n .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; }\n .wire-region.accent { border-color: oklch(84% 0.19 80.46 / 0.5); background: oklch(84% 0.19 80.46 / 0.06); }\n .wire-region.accent span { color: var(--ks-kinpaku-rich); }\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-lacquer); }\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: .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); }\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: .58rem; 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-graphite); }\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-graphite-2); }\n 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; }\n button.choose:hover { background: var(--ks-kinpaku-pale); }\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: 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); }\n #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; }\n #steer:focus { outline: none; border-color: var(--ks-patina); }\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; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; }\n .bp-note { text-align: right; }\n .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; }\n .bp-opt + .bp-opt { border-left: 1px solid var(--ks-rule); }\n .bp-opt.active { color: var(--ks-dark-ink); background: var(--ks-kinpaku-rich); }\n .bp-opt:not(.active):hover { color: var(--ks-text); }\n .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; }\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(4% 0.004 95 / 0.72); 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-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); }\n .bp-confirm-panel h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; color: var(--ks-champagne); 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-kinpaku); color: var(--ks-dark-ink); border: 0; font: inherit; font-weight: 500; padding: 9px 22px; border-radius: 6px; cursor: pointer; }\n .bp-confirm-go:hover { background: var(--ks-kinpaku-pale); }\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; 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; }\n .reroll-btn:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }\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: 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; }\n #canon:hover { opacity: .85; }\n #canon[disabled] { opacity: .18; cursor: default; }\n .card.skeleton .media { background: var(--ks-graphite); }\n .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; }\n .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; }\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-champagne); 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-champagne); text-align: center; }\n .stall .choose { align-self: center; margin-top: 0; }\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 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>\n <span class=\"wordmark\">Impeccable</span>\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…</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});` : ''",
|
|
];
|