/* "Instrument Grade" foundation (ui-visual-redesign, 2026-08-26).
   Dark theater default; day mode via the data-theme override block below.
   Fonts are SELF-HOSTED woff2 (static/fonts/, OFL 1.1 — see fonts/FONTS.md):
   the offline-demo-room guarantee of spec §5.3 is kept; only its "no external
   fonts" mechanism was superseded by the 2026-08-26 redesign task. Every face
   has a full system fallback stack, so a missing file degrades gracefully. */

@font-face {
  font-family: "Inter";
  src: url("/static/fonts/inter/Inter-Bold.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "JetBrains Mono";
  src: url("/static/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "JetBrains Mono";
  src: url("/static/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2") format("woff2");
  font-weight: 500; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "JetBrains Mono";
  src: url("/static/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}
/* ---- tokens -------------------------------------------------------------
   Blue-biased near-black neutrals; hairlines are ALPHA-white so they sit on
   any surface; --edge is the stronger line (kept name — pre-redesign rules
   reference it). Zone hues: --accent = local/keys, --cipher = wire/cipher-
   text, --blind = the platform segment the demo cannot see into. */
:root {
  --bg: #0c0f16; --panel: #12161f; --panel-2: #0f131b;
  --ink: #e8ebf2; --dim: #99a1b3; --faint: #5c6577;
  --line: rgba(255, 255, 255, 0.08); --edge: rgba(255, 255, 255, 0.16);
  --grid-dot: rgba(255, 255, 255, 0.09);
  --accent: #4cc38a; --accent-dim: rgba(76, 195, 138, 0.14);
  --cipher: #7aa2f7; --cipher-dim: rgba(122, 162, 247, 0.13);
  --blind: #a08fe0; --blind-dim: rgba(160, 143, 224, 0.12);
  --warn: #e0b45c; --err: #e0687a; --btn-ink: #08130d;
  --disc-bg: #191510; --disc-ink: #f0e6d2;
  /* The intro figure's flashy frame: white line + white glow on the dark
     theater; day mode keeps the white line readable with an ink glow. */
  --fig-frame: #ffffff; --fig-glow: rgba(255, 255, 255, 0.28);
  /* Display role rides the system face (live review 2026-08-26: Martian
     Mono rejected everywhere); the engraved look survives via the small
     sizes + letter-spacing + uppercase where applied. */
  --font-display: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-data: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  --ease-settle: cubic-bezier(0.22, 1, 0.36, 1);
  --ring: 0 0 0 1px var(--line);
}
/* Day mode: the header toggle flips data-theme on <html>. Session-only —
   dark is the default on every load, nothing is persisted. Same tokens,
   light values; no rule below hardcodes a color outside these blocks. */
html[data-theme="light"] {
  --bg: #f4f5f8; --panel: #ffffff; --panel-2: #eef0f5;
  --ink: #171c28; --dim: #5c6577; --faint: #8b93a5;
  --line: rgba(20, 28, 45, 0.12); --edge: rgba(20, 28, 45, 0.22);
  --grid-dot: rgba(23, 28, 40, 0.12);
  --accent: #1d8a58; --accent-dim: rgba(29, 138, 88, 0.12);
  --cipher: #3563c9; --cipher-dim: rgba(53, 99, 201, 0.11);
  --blind: #6a55b8; --blind-dim: rgba(106, 85, 184, 0.10);
  --warn: #99690a; --err: #c23a52; --btn-ink: #ffffff;
  --disc-bg: #fdf6e3; --disc-ink: #5c4a1e;
  --fig-frame: #ffffff; --fig-glow: rgba(23, 28, 40, 0.22);
}
* { box-sizing: border-box; }
/* MUST outrank the display: grid/flex rules below. Author-origin `display`
   beats the UA's [hidden] rule, so without !important el.hidden = true is a
   no-op and act 3 shows its before/after panes before you press Encrypt. */
[hidden] { display: none !important; }
body {
  margin: 0; color: var(--ink);
  /* The instrument's graph paper: a faint dotted tile over the ground. */
  background-color: var(--bg);
  background-image: radial-gradient(var(--grid-dot) 1px, transparent 1.25px);
  background-size: 22px 22px;
  font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
header {
  display: flex; align-items: center; gap: 1rem;
  padding: 1rem 2rem; border-bottom: 1px solid var(--line);
}
/* The masthead: knot logo, Inter Bold wordmark (brand specimen, live
   review 2026-08-26), tagline beneath. */
#logo { width: 34px; height: auto; flex: none; }
.masthead { display: flex; flex-direction: column; align-items: flex-start; gap: .05rem; }
h1 {
  font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
  font-size: 1.25rem; font-weight: 700; letter-spacing: -0.015em; margin: 0;
}
#app-tagline { margin: 0; color: var(--dim); font-size: .95rem; }
/* The title is a real button — click = home. Strips the global chrome. */
#app-title, #nav-back {
  background: transparent; color: inherit; border: 0; border-radius: 0;
  padding: 0; margin: 0; font: inherit; cursor: pointer; text-align: left;
}
#app-title:hover { color: var(--accent); filter: none; }
/* Back lives in the MAIN panel (with the act content, not the header bar),
   stepping to the previous act. */
#nav-back {
  display: block; color: var(--dim);
  font-family: var(--font-data); font-size: .85rem;
  margin: 0 0 .8rem;
}
#nav-back:hover { color: var(--ink); text-decoration: underline; filter: none; }
#app-title:focus-visible, #nav-back:focus-visible {
  box-shadow: none; outline: 2px solid var(--accent); outline-offset: 2px;
}
#conn-dot { width: .5rem; height: .5rem; border-radius: 50%; background: var(--err); }
#conn-dot.live { background: var(--accent); }
/* Fluid shell (live review): the page follows the window up to a wide cap
   instead of holding a fixed centered column; inner grids decide how to
   spend the width (auto-fit columns, capped prose). */
main { max-width: min(94vw, 98rem); margin: 0 auto; padding: 2rem; }
/* Act changes refocus rather than slide: a brief blur resolving to sharp —
   the page's signature "into focus" move, at navigation speed. */
.act { animation: refocus .25s var(--ease-settle); }
@keyframes refocus { from { opacity: 0; filter: blur(3px); } }
button {
  background: var(--accent); color: var(--btn-ink); border: 0; border-radius: .5rem;
  padding: .7rem 1.4rem; font-size: 1rem; font-weight: 600; cursor: pointer;
  margin: .5rem .5rem .5rem 0;
  transition: transform 150ms ease, filter 150ms ease;
}
button:hover { filter: brightness(1.06); }
button:active { transform: scale(.96); }
button:focus-visible {
  outline: 0;
  box-shadow: 0 0 0 1px var(--bg),
    0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent);
}
button:disabled { opacity: .4; cursor: default; transform: none; }
#access-cancel {
  background: transparent; color: var(--dim); border: 1px solid var(--edge);
}
#change-key, #theme-toggle {
  background: transparent; color: var(--dim); border: 0; border-radius: 0;
  padding: .1rem .3rem; margin: 0;
  font-family: var(--font-data); font-size: .78rem; font-weight: 400;
}
#theme-toggle { margin-left: auto; font-size: 1rem; line-height: 1; }
#change-key:hover { text-decoration: underline; }
input, select, pre { font-family: var(--font-data); }
input, select {
  background: var(--panel-2); color: var(--ink); border: 1px solid var(--line);
  border-radius: .5rem; padding: .6rem .8rem; width: 100%; max-width: 28rem;
  transition: border-color 150ms ease, box-shadow 150ms ease;
}
input:focus-visible, select:focus-visible {
  outline: 0; border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent);
}
input.invalid { border-color: var(--err); }
.error { color: var(--err); min-height: 1.2rem; }
.caption { color: var(--faint); font-style: italic; font-size: .85rem; }
.disclosure {
  border-left: 3px solid var(--warn); background: var(--disc-bg);
  padding: .8rem 1rem; color: var(--disc-ink);
  border-radius: 0 .5rem .5rem 0; max-width: 44rem;
}
/* Home-screen cascade (live review): every arrival on act 1 builds the
   presentation top-to-bottom, left-to-right, at the keygen-reveal pace
   (rise + 40ms steps); each model card takes its own step (--i set by
   renderModels). Flattened to instant under reduced motion. */
#intro-title { animation: rise .35s var(--ease-settle) both; }
#intro-paras { animation: rise .35s var(--ease-settle) both; animation-delay: 40ms; }
/* Two animations: the cascade's rise AND the frame pulse — this selector
   outranks bare .fhe-fig, so the pulse MUST ride here or it never runs
   (root cause of the invisible bounce, live review). fig-pulse is listed
   last so it owns `transform`; rise keeps opacity+blur. */
#act1 .fhe-fig {
  animation: rise .35s var(--ease-settle) 80ms both,
             fig-pulse 2.6s ease-in-out infinite;
}
#act1 .fhe-fig:hover, #act1 .fhe-fig:focus-visible {
  animation: rise .35s var(--ease-settle) 80ms both;
  transform: scale(1.05);
}
#act1 [data-copy-title="act1"] { animation: rise .35s var(--ease-settle) both; animation-delay: 120ms; }
.card {
  animation: rise .35s var(--ease-settle) both;
  animation-delay: calc(160ms + var(--i, 0) * 40ms);
}

/* Home-screen intro: the encryption story (left half) beside the animated
   FHE figure (right half); stacks on narrow windows. */
.intro-grid {
  display: grid; grid-template-columns: 1fr 1fr; gap: clamp(2rem, 4vw, 4.5rem);
  align-items: center; margin-bottom: 2.2rem;
}
@media (max-width: 46rem) { .intro-grid { grid-template-columns: 1fr; } }
#intro-paras p { color: var(--ink); margin: 0 0 .8rem; }
#intro-paras p:last-child { margin-bottom: 0; }

/* ---- the FHE explainer figure ----
   Decorative and educational ONLY: aria-hidden SVG, never bound to live
   pipeline events (HON-6.3 owns anything that looks like progress — that
   is the execution strip's job, not this one's). Animates on hover/focus;
   the concept is fixed: plaintext exists only beside the client, the
   server only ever touches cipher. Colors come from theme tokens only. */
/* The animation's window (live review): a flashy WHITE frame with a soft
   glow that swells and shrinks slightly on the floating 1011's rhythm
   (2.6s ease-in-out); hovering (or focusing) settles it smoothly into the
   swollen form for as long as the pointer stays. */
.fhe-fig {
  margin: 0; justify-self: center; cursor: default; max-width: 31rem; width: 100%;
  background: var(--panel); border: 2px solid var(--fig-frame); border-radius: 10px;
  box-shadow: 0 0 0 1px var(--fig-glow), 0 0 16px var(--fig-glow);
  padding: .8rem .8rem .5rem;
  transition: transform .35s var(--ease-settle);
  animation: fig-pulse 2.6s ease-in-out infinite;
}
@keyframes fig-pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.05); }
}
.fhe-fig:hover, .fhe-fig:focus-visible { animation: none; transform: scale(1.05); }
.fhe-fig:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: .5rem; }
.fhe-fig svg { display: block; width: 100%; height: auto; }
.fhe-fig figcaption {
  margin-top: .4rem; color: var(--faint); font-size: .92rem;
  font-style: italic; text-align: center;
}
.fhe-fig .node { fill: none; stroke: var(--line); stroke-width: 1.5; }
.fhe-fig .wire { stroke: var(--line); stroke-dasharray: 4 4; }
.fhe-fig .lbl { fill: var(--dim); font: 13px ui-monospace, monospace; text-anchor: middle; }
.fhe-fig .lock rect { fill: none; stroke: var(--dim); stroke-width: 2; }
.fhe-fig .lock .shackle {
  fill: none; stroke: var(--dim); stroke-width: 2;
  transform: translateY(-3px); transition: transform .3s var(--ease-settle);
}
.fhe-fig .packet rect { fill: none; stroke: var(--accent); stroke-width: 2; }
.fhe-fig .bits { font: 13px ui-monospace, monospace; text-anchor: middle; }
.fhe-fig .plain { fill: var(--accent); }
.fhe-fig .cipher { fill: var(--cipher); opacity: 0; filter: drop-shadow(0 0 2px var(--cipher)); }
.fhe-fig .packet { transform: translate(90px, 92px); }
.fhe-fig:hover .shackle, .fhe-fig:focus-visible .shackle { transform: translateY(0); }
.fhe-fig:hover .packet, .fhe-fig:focus-visible .packet {
  animation: fhe-travel 3.6s ease-in-out infinite;
}
.fhe-fig:hover .packet rect, .fhe-fig:focus-visible .packet rect {
  animation: fhe-stroke 3.6s ease-in-out infinite;
}
.fhe-fig:hover .plain, .fhe-fig:focus-visible .plain { animation: fhe-to-cipher 3.6s infinite; }
.fhe-fig:hover .cipher, .fhe-fig:focus-visible .cipher { animation: fhe-to-plain 3.6s infinite; }
/* The swap happens as the packet crosses the LOCK (~quarter points of the
   trip), not at the server: past the lock, only cipher exists. */
@keyframes fhe-travel {
  0% { transform: translate(90px, 92px); }
  45%, 55% { transform: translate(270px, 92px); }
  100% { transform: translate(90px, 92px); }
}
@keyframes fhe-to-cipher { 0%, 20% { opacity: 1; } 26%, 74% { opacity: 0; } 80%, 100% { opacity: 1; } }
@keyframes fhe-to-plain { 0%, 20% { opacity: 0; } 26%, 74% { opacity: 1; } 80%, 100% { opacity: 0; } }
@keyframes fhe-stroke { 0%, 20% { stroke: var(--accent); } 26%, 74% { stroke: var(--cipher); } 80%, 100% { stroke: var(--accent); } }

/* ---- duel mode (personal use) ----
   Activated by app.js ONLY when every /static/fanart/ sprite loads; the
   sprites are kept in the PRIVATE repo and ride its builds — a missing
   file always falls back to the generic figure
   above. Goku stands at the LEFT (facing right), the emperor
   at the RIGHT (facing left). One 5s choreography: charge, kamehameha to
   the lock, the beam dies as the packet crosses it, cipher glyphs travel,
   finger rises, the return beam dies at the lock too, readable again. */
.fhe-fig .duel, .fhe-fig .kame, .fhe-fig .dbeam { display: none; }
.fhe-fig.duel-on .duel { display: block; }
.fhe-fig.duel-on .kame, .fhe-fig.duel-on .dbeam { display: block; }
.fhe-fig.duel-on .kame-body, .fhe-fig.duel-on .dbeam-body { opacity: 0; }
.fhe-fig.duel-on .node { display: none; }
.fhe-fig.duel-on .duel image { image-rendering: pixelated; }
/* Duel mode plays at HAND height: the beams leave Goku's palm (~y57) and
   the emperor's fingertip (raised group => hand at y60); the packet, wire
   and lock ride the same line, measured from the sprites' actual pixels. */
/* Goku's palm sits a touch higher than the emperor's fingertip: the
   outbound leg (kamehameha + packet) rides y=56, the return leg y=60, the
   packet drifting the 4px imperceptibly along each crossing; the lock
   splits the difference. */
/* At rest the packet breathes gently beside the client — a wordless hint
   that the scene is alive (hover overrides the animation name and takes
   over). */
.fhe-fig.duel-on .packet {
  transform: translate(90px, 56px);
  animation: fhe2-idle 2.6s ease-in-out infinite;
}
@keyframes fhe2-idle {
  0%, 100% { transform: translate(90px, 56px); }
  50% { transform: translate(90px, 53.5px); }
}
.fhe-fig.duel-on .lock { transform: translate(180px, 59px) scale(1.45); }
.fhe-fig.duel-on .wire { transform: translateY(-32px); }
.fhe-fig.duel-on .duel .p2, .fhe-fig.duel-on .duel .p3 { opacity: 0; }
/* The kamehameha, built like the reference: a solid layered body (wide
   glow, lighter mid, near-white core) whose edges are torn by an animated
   turbulence filter, faint energy streaks flowing inside, and a flaming
   spherical HEAD that travels at the tip, pushing the packet. The body
   grows behind the packet (scaleX synced with its travel); the head rides
   just behind the 1011 and both die at the lock. The return laser is the
   thin violet counterpart. */
.fhe-fig.duel-on .kame-body, .fhe-fig.duel-on .dbeam-body {
  transform-box: fill-box; transform: scaleX(0);
}
.fhe-fig.duel-on .kame-body { transform-origin: left center; }
.fhe-fig.duel-on .dbeam-body { transform-origin: right center; }
.fhe-fig.duel-on .kame line, .fhe-fig.duel-on .dbeam line { stroke-linecap: round; fill: none; }
.fhe-fig.duel-on .kame-glow { stroke: var(--cipher); stroke-width: 19; opacity: .45; }
.fhe-fig.duel-on .kame-mid {
  stroke: color-mix(in srgb, var(--cipher) 55%, white); stroke-width: 11; opacity: .9;
}
.fhe-fig.duel-on .kame-core { stroke: color-mix(in srgb, var(--cipher) 12%, white); stroke-width: 5.5; }
.fhe-fig.duel-on .kame-streaks {
  stroke: color-mix(in srgb, var(--cipher) 40%, white); stroke-width: 2.5;
  stroke-dasharray: 13 9; opacity: .8;
}
.fhe-fig.duel-on .kame-head, .fhe-fig.duel-on .dbeam-head { opacity: 0; }
.fhe-fig.duel-on .kame-head { transform: translate(84px, 56px); }
.fhe-fig.duel-on .khead-glow { fill: var(--cipher); opacity: .5; }
.fhe-fig.duel-on .khead-mid { fill: color-mix(in srgb, var(--cipher) 50%, white); opacity: .9; }
.fhe-fig.duel-on .khead-core { fill: color-mix(in srgb, var(--cipher) 10%, white); }
.fhe-fig.duel-on .dbeam-glow { stroke: var(--blind); stroke-width: 9; opacity: .5; }
.fhe-fig.duel-on .dbeam-mid {
  stroke: color-mix(in srgb, var(--blind) 55%, white); stroke-width: 5; opacity: .9;
}
.fhe-fig.duel-on .dbeam-core { stroke: color-mix(in srgb, var(--blind) 15%, white); stroke-width: 2.5; }
.fhe-fig.duel-on .dbeam-head { transform: translate(292px, 60px); }
.fhe-fig.duel-on .dhead-glow { fill: var(--blind); opacity: .55; }
.fhe-fig.duel-on .dhead-core { fill: color-mix(in srgb, var(--blind) 15%, white); }
@keyframes beam-flow { to { stroke-dashoffset: -66; } }
.fhe-fig.duel-on:hover .kame-streaks, .fhe-fig.duel-on:focus-visible .kame-streaks {
  animation: beam-flow .5s linear infinite;
}
.fhe-fig.duel-on:hover .kame-head, .fhe-fig.duel-on:focus-visible .kame-head {
  animation: fhe2-kame-head 5.5s linear infinite;
}
.fhe-fig.duel-on:hover .dbeam-head, .fhe-fig.duel-on:focus-visible .dbeam-head {
  animation: fhe2-dbeam-head 5.5s linear infinite;
}
@keyframes fhe2-kame-head {
  0%, 11.4% { opacity: 0; transform: translate(84px, 56px); }
  13.3% { opacity: 1; transform: translate(88px, 56px); }
  27.6% { opacity: 1; transform: translate(167px, 56px); }
  31.4%, 100% { opacity: 0; transform: translate(167px, 56px); }
}
@keyframes fhe2-dbeam-head {
  0%, 53.3% { opacity: 0; transform: translate(292px, 60px); }
  55.2% { opacity: 1; transform: translate(288px, 60px); }
  67.6% { opacity: 1; transform: translate(193px, 60px); }
  71.4%, 100% { opacity: 0; transform: translate(193px, 60px); }
}
.fhe-fig.duel-on:hover .packet, .fhe-fig.duel-on:focus-visible .packet { animation-name: fhe2-travel; animation-duration: 5.5s; animation-timing-function: linear; }
.fhe-fig.duel-on:hover .packet rect, .fhe-fig.duel-on:focus-visible .packet rect { animation-name: fhe2-stroke; animation-duration: 5.5s; }
.fhe-fig.duel-on:hover .plain, .fhe-fig.duel-on:focus-visible .plain { animation-name: fhe2-to-cipher; animation-duration: 5.5s; }
.fhe-fig.duel-on:hover .cipher, .fhe-fig.duel-on:focus-visible .cipher { animation-name: fhe2-to-plain; animation-duration: 5.5s; }
.fhe-fig.duel-on:hover .kame-body, .fhe-fig.duel-on:focus-visible .kame-body { animation: fhe2-kame 5.5s linear infinite; }
.fhe-fig.duel-on:hover .dbeam-body, .fhe-fig.duel-on:focus-visible .dbeam-body { animation: fhe2-dbeam 5.5s linear infinite; }
.fhe-fig.duel-on:hover .duel-goku .p1, .fhe-fig.duel-on:focus-visible .duel-goku .p1 { animation: fhe2-goku-p1 5.5s step-end infinite; }
.fhe-fig.duel-on:hover .duel-goku .p2, .fhe-fig.duel-on:focus-visible .duel-goku .p2 { animation: fhe2-goku-p2 5.5s step-end infinite; }
.fhe-fig.duel-on:hover .duel-goku .p3, .fhe-fig.duel-on:focus-visible .duel-goku .p3 { animation: fhe2-goku-p3 5.5s step-end infinite; }
.fhe-fig.duel-on:hover .duel-frieza .p1, .fhe-fig.duel-on:focus-visible .duel-frieza .p1 { animation: fhe2-frz-p1 5.5s step-end infinite; }
.fhe-fig.duel-on:hover .duel-frieza .p2, .fhe-fig.duel-on:focus-visible .duel-frieza .p2 { animation: fhe2-frz-p2 5.5s step-end infinite; }
.fhe-fig.duel-on:hover .duel-frieza .p3, .fhe-fig.duel-on:focus-visible .duel-frieza .p3 { animation: fhe2-frz-p3 5.5s step-end infinite; }
/* Timeline (t% of 5.5s): the whole exchange plays inside 0-80%, and
   80-100% is a marked REST (~1.1s: both in stance, packet home, readable)
   before the loop goes again — it runs forever while hovered/focused.
   Marks: 3.8 charge, 11.4 fire+beam, 13.3 packet departs, lock at 27.6
   (beam dies), right side at 41.9; 43.8 finger up, 53.3 return fire, lock
   again at 67.6 (laser dies), home at 80. */
@keyframes fhe2-travel {
  0%, 13.3% { transform: translate(90px, 56px); }
  41.9%, 55.2% { transform: translate(270px, 60px); }
  80%, 100% { transform: translate(90px, 56px); }
}
@keyframes fhe2-to-cipher { 0%, 25.7% { opacity: 1; } 29.5%, 65.7% { opacity: 0; } 69.5%, 100% { opacity: 1; } }
@keyframes fhe2-to-plain { 0%, 25.7% { opacity: 0; } 29.5%, 65.7% { opacity: 1; } 69.5%, 100% { opacity: 0; } }
@keyframes fhe2-stroke { 0%, 25.7% { stroke: var(--accent); } 29.5%, 65.7% { stroke: var(--cipher); } 69.5%, 100% { stroke: var(--accent); } }
/* Progressive growth, synchronized with the packet (linear travel 14->44
   crosses the lock at 29): the beam's tip stays just behind the 1011 all
   the way to the lock, then the beam dies as the packet crosses. Same on
   the return (58->84, lock at 71) for the laser. */
@keyframes fhe2-kame {
  0%, 11.4% { opacity: 0; transform: scaleX(0); }
  13.3% { opacity: 1; transform: scaleX(.2); }
  27.6% { opacity: 1; transform: scaleX(1); }
  31.4%, 100% { opacity: 0; transform: scaleX(1); }
}
@keyframes fhe2-dbeam {
  0%, 53.3% { opacity: 0; transform: scaleX(0); }
  55.2% { opacity: 1; transform: scaleX(.1); }
  67.6% { opacity: 1; transform: scaleX(1); }
  71.4%, 100% { opacity: 0; transform: scaleX(1); }
}
@keyframes fhe2-goku-p1 { 0%, 2.9% { opacity: 1; } 3.8%, 31.4% { opacity: 0; } 32.4%, 100% { opacity: 1; } }
@keyframes fhe2-goku-p2 { 0%, 2.9% { opacity: 0; } 3.8%, 10.5% { opacity: 1; } 11.4%, 100% { opacity: 0; } }
@keyframes fhe2-goku-p3 { 0%, 10.5% { opacity: 0; } 11.4%, 31.4% { opacity: 1; } 32.4%, 100% { opacity: 0; } }
@keyframes fhe2-frz-p1 { 0%, 42.9% { opacity: 1; } 43.8%, 73.3% { opacity: 0; } 74.3%, 100% { opacity: 1; } }
@keyframes fhe2-frz-p2 { 0%, 42.9% { opacity: 0; } 43.8%, 52.4% { opacity: 1; } 53.3%, 100% { opacity: 0; } }
@keyframes fhe2-frz-p3 { 0%, 52.4% { opacity: 0; } 53.3%, 73.3% { opacity: 1; } 74.3%, 100% { opacity: 0; } }
/* Act 1 model list: generous row-cards (the shadcn card idiom from the
   harvest — flush ring surface, content body, demoted footer strip carrying
   the class names). ALWAYS one column (live review): the models read as a
   vertical list at every window width; rows widen with the fluid shell. */
#model-cards { display: grid; grid-template-columns: 1fr; gap: .9rem; }
/* Ring elevation: flush panels wear a 1px alpha hairline (border or ring),
   never a drop shadow — shadows are reserved for genuinely floating
   surfaces. The card keeps a real border (hover brighten + the unavailable
   red outline ride on it). */
.card {
  background: var(--panel); border: 1px solid var(--line); border-radius: .8rem;
  padding: 1.25rem 1.4rem 0; overflow: hidden; cursor: pointer;
  transition: border-color 150ms ease, transform 150ms ease, background-color 150ms ease;
}
/* The project's established hover family: hairline brightens + 1px lift
   (cards) + the history rows' surface tint — nothing new. */
.card:hover { border-color: var(--edge); transform: translateY(-1px) scale(1.012); background-color: var(--panel-2); }
.card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.card h3 { margin: 0 0 .3rem; font-family: var(--font-data); font-size: 1.05rem; font-weight: 700; }
.card p { margin: 0 0 1.1rem; color: var(--dim); font-size: .9rem; }
.card .classes {
  margin: 0 -1.4rem; padding: .55rem 1.4rem;
  color: var(--dim); font-family: var(--font-data); font-size: .76rem;
  background: var(--panel-2); border-top: 1px solid var(--line);
}
/* FR-3.15's activated-card loading visual (the comet) and the busy-dim
   were REMOVED at the product owner's direction (live review 2026-08-26):
   a click shows nothing until act 2 opens; the D3 in-flight guard in
   app.js still swallows re-clicks silently. */
/* Present-but-unavailable model (see DISABLED_MODELS in app.js): fully
   legible at rest, marked by a RED outline; hovering blurs the CONTENT
   (never the card — the note must stay sharp) and fades the copy note in
   over it. Inert via the missing handlers/tabindex; clicks land nowhere. */
.card.unavailable { cursor: not-allowed; position: relative; border-color: var(--err); }
.card.unavailable:hover { border-color: var(--err); transform: none; }
.card.unavailable > :not(.unavailable-note) {
  transition: filter 150ms ease, opacity 150ms ease;
}
.card.unavailable:hover > :not(.unavailable-note) {
  filter: blur(1.6px) saturate(.4); opacity: .4;
}
.unavailable-note {
  position: absolute; inset: 0; display: flex;
  align-items: center; justify-content: center; text-align: center;
  margin: 0; padding: 1rem; color: var(--ink); font-size: .85rem;
  opacity: 0; transition: opacity 150ms ease; pointer-events: none;
}
.card.unavailable:hover .unavailable-note { opacity: 1; }
/* ---- act 2: the keygen ceremony ----
   Armed button: one restrained light sweep while it is pressable — a "live
   and ready" cue that stops the moment the ceremony starts (disabled). */
#keygen-btn, #to-act3, #encrypt-btn { position: relative; overflow: hidden; }
#keygen-btn:not(:disabled)::after, #to-act3:not(:disabled)::after, #encrypt-btn:not(:disabled)::after {
  content: ""; position: absolute; inset: 0; left: -50%; width: 200%;
  background: linear-gradient(to right, transparent, rgba(255, 255, 255, .30) 50%, transparent);
  transform: translateX(-150%) skewX(-20deg);
  animation: sweep 2.5s linear infinite;
}
@keyframes sweep { to { transform: translateX(150%) skewX(-20deg); } }
/* The working state: spinner + shimmering status + REAL elapsed clock +
   pixel wave. Ambient only — nothing here fills toward an end (HON-6.3). */
#keygen-anim { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding: .6rem 0; }
#keygen-anim p { margin: 0; }
#keygen-spinner {
  font-family: var(--font-data); font-size: 1.35rem; color: var(--accent);
  width: 1.3em; text-align: center;
}
#keygen-status {
  font-family: var(--font-data); font-size: .85rem;
  background-image: linear-gradient(90deg, var(--dim) 35%, var(--ink) 50%, var(--dim) 65%);
  background-size: 200% 100%;
  -webkit-background-clip: text; background-clip: text; color: transparent;
  animation: shimmer-label 1.6s linear infinite;
}
#keygen-elapsed {
  font-family: var(--font-data); font-size: .8rem; color: var(--faint);
  font-variant-numeric: tabular-nums;
}
.pixelwave { display: grid; grid-template-columns: repeat(3, 5px); gap: 3px; }
.pixelwave i {
  width: 5px; height: 5px; border-radius: 1px; background: var(--accent);
  opacity: .15; animation: pixel-on 650ms ease-in-out infinite;
}
.pixelwave i:nth-child(4n+2) { animation-delay: 90ms; }
.pixelwave i:nth-child(4n+3) { animation-delay: 180ms; }
.pixelwave i:nth-child(4n+4) { animation-delay: 270ms; }
@keyframes pixel-on { 0%, 100% { opacity: .15; } 18%, 42% { opacity: 1; } 62% { opacity: .15; } }
#keygen-artifacts {
  display: grid; grid-template-columns: max-content 1fr; gap: .45rem 1.4rem;
  background: var(--panel); border-radius: .6rem; padding: 1.1rem 1.25rem;
  box-shadow: var(--ring);
}
#keygen-artifacts dt { color: var(--dim); font-size: .85rem; }
#keygen-artifacts dd { margin: 0; font-family: var(--font-data); font-size: .88rem; font-variant-numeric: tabular-nums; }
/* The reveal: rows rise out of a slight blur, staggered 40ms per row (--i is
   set per row by revealArtifacts). Plays out AFTER keygen_done — the reveal
   follows the real event, it never anticipates it. */
#keygen-artifacts > * {
  animation: rise .35s var(--ease-settle) both;
  animation-delay: calc(var(--i, 0) * 40ms);
}
#disclosure { animation: rise .4s var(--ease-settle) both; animation-delay: .26s; }
#to-act3 { animation: rise .4s var(--ease-settle) both; animation-delay: .34s; }
@keyframes rise {
  from { opacity: 0; transform: translateY(6px); filter: blur(2px); }
}
/* Act-3 head: title left, feature filter right; the chosen model beneath. */
.act3-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
#field-search { max-width: 15rem; padding: .45rem .7rem; font-size: .85rem; }
#act3-model { margin: 0 0 .6rem; color: var(--dim); font-family: var(--font-data); font-size: .82rem; }
#row-form { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: .8rem; margin-bottom: 1rem; }
#row-form label { display: block; color: var(--dim); font-family: var(--font-data); font-size: .74rem; }
/* Every field the same box: full cell width (no 28rem cap here) and one
   fixed height, so selects (sex) and inputs (chol) can't drift apart. */
#row-form input, #row-form select { max-width: none; height: 2.5rem; }
#bitgrid { display: flex; flex-wrap: wrap; gap: 3px; margin: 1rem 0; }
#bitgrid .bit {
  width: 12px; height: 12px; border-radius: 2px; background: var(--line);
  animation: pop .3s var(--ease-settle) both;
}
#bitgrid .bit.on { background: var(--accent); }
#bitgrid .bit.quant { background: var(--cipher); }
@keyframes pop { from { transform: scale(0); } }
#beforeafter, #recap-beforeafter { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1rem 0; }
.pane {
  background: var(--panel); border-radius: .6rem; padding: 1rem; min-width: 0;
  box-shadow: var(--ring);
}
.pane h3 {
  margin: 0 0 .6rem; font-family: var(--font-display);
  font-size: .68rem; font-weight: 500; letter-spacing: .14em;
  text-transform: uppercase; color: var(--dim);
}
.pane pre { white-space: pre-wrap; word-break: break-all; margin: 0; font-size: .78rem; line-height: 1.7; }
#cipher-view, #recap-cipher-view { color: var(--cipher); }
/* The hexdump (act 3 live + act 4 replay, built by buildHexdump): offset
   gutter + 16 bytes per row; rows reveal 60ms apart (--i per row) — after
   the real `encrypted` event live, or on the participant's replay click. */
.hex-total { color: var(--dim); margin-bottom: .35rem; }
.hexrow {
  display: grid; grid-template-columns: 4.6em 1fr;
  font-size: .74rem; line-height: 1.75;
  animation: rise .3s var(--ease-settle) both;
  animation-delay: calc(var(--i, 0) * 60ms);
}
.hexoff { color: var(--faint); user-select: none; }
.hexbytes { color: var(--cipher); overflow-wrap: anywhere; }
/* The decision resolves into focus — a decrypt made visible. Plays out
   after the authoritative /api/predict response (HON-6.3); the scores and
   the recap rows follow in sequence (rise delays below). */
#result-label {
  font-family: var(--font-display);
  font-size: 2.2rem; font-weight: 700; margin: 1rem 0;
  animation: resolve .6s var(--ease-settle) both;
}
@keyframes resolve {
  from { opacity: 0; transform: translateY(14px) scale(.97); filter: blur(10px); }
}
#result-scores {
  color: var(--dim); font-family: var(--font-data); font-size: .88rem;
  animation: rise .4s var(--ease-settle) both; animation-delay: .22s;
}
#result-cipher { animation: rise .4s var(--ease-settle) both; animation-delay: .55s; }
#stage-line { color: var(--dim); min-height: 1.5rem; }
/* Replayed by the stage arm on each REAL token change (the .swap class is
   re-added after a reflow): the new label rises in; unknown tokens keep the
   last label and trigger nothing. */
#stage-line.swap { animation: rise .25s var(--ease-settle); }
@media (max-width: 40rem) { #beforeafter { grid-template-columns: 1fr; } }

/* ---- Addendum: execution-path strip (screens 3 and 4) ----
   The signal path. Zone hue enters through a 2px top rule: --accent = the
   participant's machine, --cipher = the wire, --blind = the platform. */
.strip { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
.strip:empty { margin: 0; }
.strip .zone {
  display: flex; flex-direction: column; gap: .3rem; flex: 1 1 auto;
  background: var(--panel); border-radius: .5rem; box-shadow: var(--ring);
  border-top: 2px solid var(--zh, var(--edge));
  padding: .55rem .75rem; min-width: 9rem;
}
/* Zone hues, worn as the 2px top rule and inherited by done dots:
   green = this machine, blue = the wire, violet = the platform. */
.strip .zone.local { --zh: var(--accent); }
.strip .zone.api { --zh: var(--cipher); }
/* The platform zone wears a cross-hatch: the one segment of the path the
   demo cannot see into. Its step also has NO active state (see app.js —
   pending -> done retrospectively on roundtrip_done, ADD-EXEC-5/6). */
.strip .zone.platform {
  --zh: var(--blind);
  background-image: repeating-linear-gradient(45deg, transparent 0 7px, var(--line) 7px 8px);
}
.strip .zone-label {
  color: var(--dim); font-family: var(--font-display);
  font-size: .56rem; font-weight: 500; letter-spacing: .16em;
  text-transform: uppercase;
}
.strip .step { display: flex; align-items: baseline; gap: .5rem; font-size: .85rem; }
/* Pending = a hollow ring; active = breathing amber; done = filled with the
   zone's own hue. State is never motion-only: color + fill carry it too. */
.strip .step::before {
  content: ""; width: .5rem; height: .5rem; border-radius: 50%;
  background: transparent; box-shadow: inset 0 0 0 1.5px var(--faint);
  flex: none; align-self: center;
}
.strip .step.pending { color: var(--dim); }
.strip .step.active { color: var(--ink); }
/* The pulse plays out AFTER the real event that authorized it (HON-6.3) —
   it marks in-progress, it never predicts completion. */
.strip .step.active::before {
  background: var(--warn); box-shadow: none;
  animation: pulse 1.1s ease-in-out infinite;
}
/* Ambient "still working" sheen on the active label: it holds, it never
   fills toward an end — no completion is predicted (HON-6.3). */
.strip .step.active .step-label {
  background-image: linear-gradient(90deg, var(--dim) 35%, var(--ink) 50%, var(--dim) 65%);
  background-size: 200% 100%;
  -webkit-background-clip: text; background-clip: text; color: transparent;
  animation: shimmer-label 2s linear infinite;
}
@keyframes shimmer-label { 0% { background-position: 150% 0; } 100% { background-position: -50% 0; } }
.strip .step.done { color: var(--ink); }
.strip .step.done::before { background: var(--zh, var(--accent)); box-shadow: none; }
.strip .step-duration { color: var(--faint); font-family: var(--font-data); font-size: .74rem; font-variant-numeric: tabular-nums; }
@keyframes pulse { 50% { opacity: .35; transform: scale(.8); } }

/* ---- Addendum: sizes panel (screen 3) ---- */
#sizes-panel {
  display: grid; grid-template-columns: max-content 1fr; gap: .4rem 1.6rem;
  background: var(--panel); border-radius: .6rem; padding: 1.1rem 1.25rem;
  margin: 1rem 0; box-shadow: var(--ring);
}
#sizes-panel dt { color: var(--dim); font-size: .85rem; }
#sizes-panel dd { margin: 0; font-family: var(--font-data); font-size: .88rem; font-variant-numeric: tabular-nums; }

/* ---- Addendum: transmission inventory (screen 3) ---- */
#xmit { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1rem 0; }
#xmit .xmit-block {
  background: var(--panel); border-radius: .6rem; padding: 1rem 1.1rem;
  min-width: 0; box-shadow: var(--ring);
}
#xmit h4 {
  margin: 0 0 .5rem; font-family: var(--font-display);
  font-size: .62rem; font-weight: 700; letter-spacing: .16em; color: var(--dim);
}
#xmit h4 + p, #xmit ul + h4 { margin-top: .8rem; }
#xmit ul { margin: 0; padding-left: 1.2rem; font-size: .85rem; }
#xmit li { margin: .15rem 0; }
@media (max-width: 40rem) { #xmit { grid-template-columns: 1fr; } }

/* ---- Addendum: screen-4 recap ---- */
#recap-title {
  color: var(--accent); font-family: var(--font-display);
  font-size: .72rem; font-weight: 700; letter-spacing: .18em; text-transform: uppercase;
}
#recap-stats {
  display: grid; grid-template-columns: max-content 1fr; gap: .4rem 1.6rem;
  background: var(--panel); border-radius: .6rem; padding: 1.1rem 1.25rem;
  margin: 1rem 0; box-shadow: var(--ring);
}
#recap-stats dt { color: var(--dim); font-size: .85rem; }
#recap-stats dd { margin: 0; font-family: var(--font-data); font-size: .88rem; font-variant-numeric: tabular-nums; }
/* Rows rise in sequence after the label (--i set by renderRecapStats);
   every value is from the one authoritative response, already in hand. */
#recap-stats > * {
  animation: rise .35s var(--ease-settle) both;
  animation-delay: calc(.25s + var(--i, 0) * 40ms);
}
/* ADD-RECAP-2: the Total inference pipeline row is the prominent one. */
#recap-stats dt.total, #recap-stats dd.total {
  color: var(--ink); font-size: 1.15rem; font-weight: 700; margin-top: .4rem;
  padding-top: .5rem; border-top: 1px solid var(--line);
}
#recap-stats dd.total { color: var(--accent); }

/* Act-4 replay toggle: a discreet control under the completed strip that
   reopens the act-3 encryption panel for THIS run. */
#recap-cipher-toggle {
  background: transparent; color: var(--dim); border: 1px solid var(--edge);
  border-radius: .4rem; padding: .3rem .7rem; margin: 0 0 .4rem;
  font-family: var(--font-data); font-size: .74rem; font-weight: 400;
}
#recap-cipher-toggle[aria-expanded="true"] { color: var(--cipher); border-color: var(--cipher); }

/* ---- Addendum: inference history (screen 4) ---- */
#history { margin: 1.5rem 0; }
#history summary {
  cursor: pointer; color: var(--dim); font-family: var(--font-display);
  font-size: .66rem; font-weight: 500; letter-spacing: .16em;
}
/* History reads as data rows: no list markers, hairline dividers, mono slug
   left, tabular duration pushed right, eye control at the far edge. */
#history-list { margin: .5rem 0 0; padding-left: 0; font-size: .85rem; list-style: none; }
#history-list li { margin: 0; padding: .3rem .4rem; border-bottom: 1px solid var(--line); }
#history-list li:hover { background: var(--panel-2); }
.history-line { display: flex; align-items: center; gap: .8rem; width: 100%; }
.history-model { font-family: var(--font-data); color: var(--dim); font-size: .8rem; min-width: 6.5rem; }
.history-result { font-weight: 600; }
.history-duration { margin-left: auto; color: var(--faint); font-family: var(--font-data); font-size: .76rem; font-variant-numeric: tabular-nums; }
button.eye {
  background: transparent; border: 1px solid var(--edge); border-radius: .4rem;
  color: var(--ink); padding: 0 .35rem; margin: 0; font-size: .85rem;
  line-height: 1.4;
}
button.eye:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.eye[aria-expanded="true"] { border-color: var(--accent); color: var(--accent); }
.history-row {
  background: var(--panel-2); border-radius: .5rem; padding: .6rem .8rem;
  margin: .4rem 0 .2rem; font-size: .75rem; white-space: pre-wrap; word-break: break-all;
  /* Restarts whenever the row is unhidden — a user action, animation-safe. */
  animation: rise .25s var(--ease-settle);
}

/* ---- accessibility: reduce every animation to a state change ----
   No information is motion-only (states also render as text and color), so
   flattening durations loses nothing but the theater. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }
}
